feat(protocol): deliver Activity Lease model (Zero Background Workers)

Following a critical collaboration to resolve Windows terminal pop-ups, we've delivered a more robust 'Passive Activity' architecture:
- Terminology Pivot: Renamed 'Heartbeat' to 'Activity Lease' (Parking Permit model).
- Side-Effect Extension: tools/bb.ts now automatically extends the agent's lease whenever they perform real work (any CLI command).
- Passive Handshake: bb-init.mjs now only performs an initial registration/lease start, with no background loops.
- 100% Silence: Removed all background process spawning, ensuring zero terminal disruption on Windows.
- High Observability: Liveness is still tracked via the 15m threshold, but relies on activity rather than periodic pings.

OPERATIVE: silver-castle
SESSION: 2026-02-14-1330
This commit is contained in:
zenchantlive 2026-02-14 11:18:40 -08:00
parent 5b9c0aa6a3
commit e010e0b10b
6 changed files with 69 additions and 59 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,17 @@
#!/usr/bin/env node
/**
* bb-init.mjs - Agent Session Bootstrapper (Passive Version)
* bb-init.mjs - Agent Session Bootstrapper (Lease-Based)
*
* Part of Operative Protocol v1 (bb-u6f.6.3)
*
* Responsibility:
* 1. Resolve bb.ps1 path.
* 2. Identify agent (adopt or register).
* 3. Start the initial activity lease.
*
* Note: No background processes are spawned. Liveness is maintained
* via passive side-effects of CLI commands (Activity-based model).
*/
import { parseArgs } from 'node:util';
@ -102,15 +106,15 @@ async function main() {
const role = values.role || 'agent';
execSync(`${bbExec} agent register --name ${agentId} --role ${role} --json`, { stdio: 'ignore' });
} else {
// For adoption or auto, we just do a heartbeat to show we are alive
execSync(`${bbExec} agent heartbeat --agent ${agentId} --json`, { stdio: 'ignore' });
// Start/Extend the lease to show we are now active
execSync(`${bbExec} agent activity-lease --agent ${agentId} --json`, { stdio: 'ignore' });
}
log({
ok: true,
agent_id: agentId,
mode,
heartbeat: { status: 'passive', note: 'Heartbeat managed via passive command side-effects' },
lease: { status: 'active', note: 'Activity lease started. Liveness maintained via real work.' },
timestamp: new Date().toISOString()
});

View file

@ -4,7 +4,7 @@ import path from 'node:path';
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent heartbeat';
export type AgentCommandName = 'agent register' | 'agent list' | 'agent show' | 'agent activity-lease';
export interface AgentCommandError {
code: string;
@ -24,7 +24,7 @@ export interface AgentRecord {
role: string;
status: string;
created_at: string;
last_seen_at: string;
last_seen_at: string; // Used as the base for the Activity Lease
version: number;
}
@ -48,7 +48,7 @@ export interface ShowAgentInput {
agent: string;
}
export interface HeartbeatAgentInput {
export interface ActivityLeaseInput {
agent: string;
}
@ -280,13 +280,14 @@ export function deriveLiveness(lastSeenAt: string, now: Date = new Date(), stale
}
/**
* Updates the last_seen_at timestamp for a registered agent.
* Extends the activity lease (last_seen_at timestamp) for a registered agent.
* Equivalent to a "parking permit" extension based on real work.
*/
export async function heartbeatAgent(
input: HeartbeatAgentInput,
export async function extendActivityLease(
input: ActivityLeaseInput,
deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent heartbeat';
const command: AgentCommandName = 'agent activity-lease';
const agentId = trimOrEmpty(input.agent);
const agentIdError = validateAgentId(agentId);
@ -310,6 +311,6 @@ export async function heartbeatAgent(
await writeAgent(updated);
return success(command, updated);
} catch (error) {
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to heartbeat agent.');
return invalid(command, 'INTERNAL_ERROR', error instanceof Error ? error.message : 'Failed to extend activity lease.');
}
}

View file

@ -6,7 +6,7 @@ import path from 'node:path';
import {
registerAgent,
heartbeatAgent,
extendActivityLease,
deriveLiveness,
agentFilePath,
} from '../../src/lib/agent-registry';
@ -29,7 +29,7 @@ async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
}
}
test('heartbeatAgent updates last_seen_at and increments version', async () => {
test('extendActivityLease updates last_seen_at and increments version', async () => {
await withTempUserProfile(async () => {
const start = '2026-02-14T10:00:00.000Z';
const next = '2026-02-14T10:05:00.000Z';
@ -39,7 +39,7 @@ test('heartbeatAgent updates last_seen_at and increments version', async () => {
{ now: () => start }
);
const result = await heartbeatAgent(
const result = await extendActivityLease(
{ agent: 'active-agent' },
{ now: () => next }
);

View file

@ -10,7 +10,7 @@ const initScript = path.join(projectRoot, 'scripts', 'bb-init.mjs');
async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE;
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-init-passive-'));
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-init-lease-'));
process.env.USERPROFILE = tempDir;
// Initialize a fake git repo
@ -22,13 +22,21 @@ async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promis
await run(tempDir);
} finally {
process.env.USERPROFILE = previous;
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 5 });
// Cleanup with retries for Windows
for (let i = 0; i < 5; i++) {
try {
await fs.rm(tempDir, { recursive: true, force: true });
break;
} catch {
await new Promise(r => setTimeout(r, 500));
}
}
}
}
test('PASSIVE: bb-init --register updates liveness via side-effect', async (t) => {
test('LEASE: bb-init --register updates liveness and starts lease', async () => {
await withTempRegistry(async (tempDir) => {
const agentId = 'passive-agent';
const agentId = 'lease-agent';
const cmd = `node ${initScript} --register ${agentId} --role backend --json`;
const out = execSync(cmd, {
@ -39,7 +47,7 @@ test('PASSIVE: bb-init --register updates liveness via side-effect', async (t) =
const result = JSON.parse(out);
assert.equal(result.ok, true);
assert.equal(result.heartbeat.status, 'passive');
assert.equal(result.lease.status, 'active');
// Verify Registry Entry exists and has a timestamp
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
@ -49,35 +57,27 @@ test('PASSIVE: bb-init --register updates liveness via side-effect', async (t) =
});
});
test('PASSIVE: bb-init --adopt rejection still works with noise filtering', async (t) => {
test('LEASE: activity-lease command works via CLI', async () => {
await withTempRegistry(async (tempDir) => {
const agentId = 'noise-agent';
// Register first
const agentId = 'cli-agent';
// Register
execSync(`node ${initScript} --register ${agentId} --role test --json`, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot }
});
// Rejects with only .beadboard noise
try {
execSync(`node ${initScript} --adopt ${agentId} --non-interactive --json`, {
cwd: tempDir,
stdio: 'pipe',
env: { ...process.env, BB_REPO: projectRoot }
});
assert.fail('Should have rejected adoption');
} catch (err: any) {
const res = JSON.parse(err.stdout.toString());
assert.equal(res.error.code, 'ADOPTION_REJECTED');
}
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
const firstSeen = JSON.parse(await fs.readFile(agentFile, 'utf8')).last_seen_at;
// Accepts with real change
await fs.writeFile(path.join(tempDir, 'real.ts'), 'code');
const adoptOut = execSync(`node ${initScript} --adopt ${agentId} --non-interactive --json`, {
// Extend lease
await new Promise(r => setTimeout(r, 100)); // Ensure clock tick
const bbPath = path.join(projectRoot, 'tools', 'bb.ts');
execSync(`npx tsx ${bbPath} agent activity-lease --agent ${agentId} --json`, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot }
});
assert.equal(JSON.parse(adoptOut).ok, true);
const secondSeen = JSON.parse(await fs.readFile(agentFile, 'utf8')).last_seen_at;
assert.notEqual(firstSeen, secondSeen, 'Lease extension should update last_seen_at');
});
});

View file

@ -1,6 +1,6 @@
import { parseArgs } from 'node:util';
import {
registerAgent, listAgents, showAgent, heartbeatAgent, type AgentCommandResponse
registerAgent, listAgents, showAgent, extendActivityLease, type AgentCommandResponse
} from '../src/lib/agent-registry';
import {
sendAgentMessage, inboxAgentMessages, readAgentMessage, ackAgentMessage,
@ -45,6 +45,9 @@ function printResponse(response: AnyCommandResponse, json: boolean) {
} else if (response.command === 'agent show') {
const d = response.data;
console.log(`Agent: ${d.agent_id}\nRole: ${d.role}\nStatus: ${d.status}\nLast Seen: ${d.last_seen_at}`);
} else if (response.command === 'agent activity-lease') {
const d = response.data;
console.log(`✓ Activity lease extended: ${d.agent_id} (version: ${d.version})`);
} else if (response.command === 'agent send') {
const d = response.data;
console.log(`✓ Message sent: ${d.message_id} (state: ${d.state})`);
@ -81,6 +84,7 @@ Commands:
register Register or update an agent identity
list List registered agents
show Show one registered agent
activity-lease Extend the activity lease (silent refresh)
send Send a message to an agent
inbox List inbox messages for an agent
read Mark one message as read
@ -169,11 +173,12 @@ async function main() {
try {
let result: AnyCommandResponse;
// PASSIVE HEARTBEAT: If an agent is specified in any command, update their liveness.
// This provides observability without background workers.
// ACTIVITY LEASE (Passive): Whenever an agent ID is provided in any command,
// we extend their lease as a side-effect of real work.
// This provides observability WITHOUT background workers or popups.
const targetAgent = stringArg(values.agent) || stringArg(values.from) || stringArg(values.name);
if (targetAgent && command !== 'register') {
await heartbeatAgent({ agent: targetAgent }, deps).catch(() => {});
await extendActivityLease({ agent: targetAgent }, deps).catch(() => {});
}
switch (command) {
@ -188,13 +193,6 @@ async function main() {
}, deps);
break;
case 'heartbeat':
if (!values.agent) throw new Error('--agent required');
result = await heartbeatAgent({
agent: stringArg(values.agent)!,
}, deps);
break;
case 'list':
result = await listAgents({
role: stringArg(values.role),
@ -207,6 +205,13 @@ async function main() {
result = await showAgent({ agent: stringArg(values.agent)! });
break;
case 'activity-lease':
if (!values.agent) throw new Error('--agent required');
result = await extendActivityLease({
agent: stringArg(values.agent)!,
}, deps);
break;
// --- Mail ---
case 'send':
if (!values.from || !values.to || !values.bead || !values.category || !values.subject || !values.body) {