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 #!/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) * Part of Operative Protocol v1 (bb-u6f.6.3)
* *
* Responsibility: * Responsibility:
* 1. Resolve bb.ps1 path. * 1. Resolve bb.ps1 path.
* 2. Identify agent (adopt or register). * 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'; import { parseArgs } from 'node:util';
@ -102,15 +106,15 @@ async function main() {
const role = values.role || 'agent'; const role = values.role || 'agent';
execSync(`${bbExec} agent register --name ${agentId} --role ${role} --json`, { stdio: 'ignore' }); execSync(`${bbExec} agent register --name ${agentId} --role ${role} --json`, { stdio: 'ignore' });
} else { } else {
// For adoption or auto, we just do a heartbeat to show we are alive // Start/Extend the lease to show we are now active
execSync(`${bbExec} agent heartbeat --agent ${agentId} --json`, { stdio: 'ignore' }); execSync(`${bbExec} agent activity-lease --agent ${agentId} --json`, { stdio: 'ignore' });
} }
log({ log({
ok: true, ok: true,
agent_id: agentId, agent_id: agentId,
mode, 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() timestamp: new Date().toISOString()
}); });
@ -119,4 +123,4 @@ async function main() {
} }
} }
main(); main();

View file

@ -4,7 +4,7 @@ import path from 'node:path';
const AGENT_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; 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 { export interface AgentCommandError {
code: string; code: string;
@ -24,7 +24,7 @@ export interface AgentRecord {
role: string; role: string;
status: string; status: string;
created_at: string; created_at: string;
last_seen_at: string; last_seen_at: string; // Used as the base for the Activity Lease
version: number; version: number;
} }
@ -48,7 +48,7 @@ export interface ShowAgentInput {
agent: string; agent: string;
} }
export interface HeartbeatAgentInput { export interface ActivityLeaseInput {
agent: string; 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( export async function extendActivityLease(
input: HeartbeatAgentInput, input: ActivityLeaseInput,
deps: Partial<RegisterAgentDeps> = {}, deps: Partial<RegisterAgentDeps> = {},
): Promise<AgentCommandResponse<AgentRecord>> { ): Promise<AgentCommandResponse<AgentRecord>> {
const command: AgentCommandName = 'agent heartbeat'; const command: AgentCommandName = 'agent activity-lease';
const agentId = trimOrEmpty(input.agent); const agentId = trimOrEmpty(input.agent);
const agentIdError = validateAgentId(agentId); const agentIdError = validateAgentId(agentId);
@ -310,6 +311,6 @@ export async function heartbeatAgent(
await writeAgent(updated); await writeAgent(updated);
return success(command, updated); return success(command, updated);
} catch (error) { } 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 { import {
registerAgent, registerAgent,
heartbeatAgent, extendActivityLease,
deriveLiveness, deriveLiveness,
agentFilePath, agentFilePath,
} from '../../src/lib/agent-registry'; } 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 () => { await withTempUserProfile(async () => {
const start = '2026-02-14T10:00:00.000Z'; const start = '2026-02-14T10:00:00.000Z';
const next = '2026-02-14T10:05: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 } { now: () => start }
); );
const result = await heartbeatAgent( const result = await extendActivityLease(
{ agent: 'active-agent' }, { agent: 'active-agent' },
{ now: () => next } { 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> { async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promise<void> {
const previous = process.env.USERPROFILE; 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; process.env.USERPROFILE = tempDir;
// Initialize a fake git repo // Initialize a fake git repo
@ -22,13 +22,21 @@ async function withTempRegistry(run: (tempDir: string) => Promise<void>): Promis
await run(tempDir); await run(tempDir);
} finally { } finally {
process.env.USERPROFILE = previous; 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) => { await withTempRegistry(async (tempDir) => {
const agentId = 'passive-agent'; const agentId = 'lease-agent';
const cmd = `node ${initScript} --register ${agentId} --role backend --json`; const cmd = `node ${initScript} --register ${agentId} --role backend --json`;
const out = execSync(cmd, { 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); const result = JSON.parse(out);
assert.equal(result.ok, true); 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 // Verify Registry Entry exists and has a timestamp
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`); 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) => { await withTempRegistry(async (tempDir) => {
const agentId = 'noise-agent'; const agentId = 'cli-agent';
// Register
// Register first
execSync(`node ${initScript} --register ${agentId} --role test --json`, { execSync(`node ${initScript} --register ${agentId} --role test --json`, {
cwd: tempDir, cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot } env: { ...process.env, BB_REPO: projectRoot }
}); });
// Rejects with only .beadboard noise const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
try { const firstSeen = JSON.parse(await fs.readFile(agentFile, 'utf8')).last_seen_at;
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');
}
// Accepts with real change // Extend lease
await fs.writeFile(path.join(tempDir, 'real.ts'), 'code'); await new Promise(r => setTimeout(r, 100)); // Ensure clock tick
const adoptOut = execSync(`node ${initScript} --adopt ${agentId} --non-interactive --json`, { const bbPath = path.join(projectRoot, 'tools', 'bb.ts');
cwd: tempDir, execSync(`npx tsx ${bbPath} agent activity-lease --agent ${agentId} --json`, {
cwd: tempDir,
env: { ...process.env, BB_REPO: projectRoot } 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 { parseArgs } from 'node:util';
import { import {
registerAgent, listAgents, showAgent, heartbeatAgent, type AgentCommandResponse registerAgent, listAgents, showAgent, extendActivityLease, type AgentCommandResponse
} from '../src/lib/agent-registry'; } from '../src/lib/agent-registry';
import { import {
sendAgentMessage, inboxAgentMessages, readAgentMessage, ackAgentMessage, sendAgentMessage, inboxAgentMessages, readAgentMessage, ackAgentMessage,
@ -45,6 +45,9 @@ function printResponse(response: AnyCommandResponse, json: boolean) {
} else if (response.command === 'agent show') { } else if (response.command === 'agent show') {
const d = response.data; const d = response.data;
console.log(`Agent: ${d.agent_id}\nRole: ${d.role}\nStatus: ${d.status}\nLast Seen: ${d.last_seen_at}`); 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') { } else if (response.command === 'agent send') {
const d = response.data; const d = response.data;
console.log(`✓ Message sent: ${d.message_id} (state: ${d.state})`); console.log(`✓ Message sent: ${d.message_id} (state: ${d.state})`);
@ -78,10 +81,11 @@ function printAgentHelp() {
console.log(`Usage: bb agent <command> [options] console.log(`Usage: bb agent <command> [options]
Commands: Commands:
register Register or update an agent identity register Register or update an agent identity
list List registered agents list List registered agents
show Show one registered agent show Show one registered agent
send Send a message to an agent activity-lease Extend the activity lease (silent refresh)
send Send a message to an agent
inbox List inbox messages for an agent inbox List inbox messages for an agent
read Mark one message as read read Mark one message as read
ack Acknowledge one message ack Acknowledge one message
@ -169,11 +173,12 @@ async function main() {
try { try {
let result: AnyCommandResponse; let result: AnyCommandResponse;
// PASSIVE HEARTBEAT: If an agent is specified in any command, update their liveness. // ACTIVITY LEASE (Passive): Whenever an agent ID is provided in any command,
// This provides observability without background workers. // 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); const targetAgent = stringArg(values.agent) || stringArg(values.from) || stringArg(values.name);
if (targetAgent && command !== 'register') { if (targetAgent && command !== 'register') {
await heartbeatAgent({ agent: targetAgent }, deps).catch(() => {}); await extendActivityLease({ agent: targetAgent }, deps).catch(() => {});
} }
switch (command) { switch (command) {
@ -188,13 +193,6 @@ async function main() {
}, deps); }, deps);
break; break;
case 'heartbeat':
if (!values.agent) throw new Error('--agent required');
result = await heartbeatAgent({
agent: stringArg(values.agent)!,
}, deps);
break;
case 'list': case 'list':
result = await listAgents({ result = await listAgents({
role: stringArg(values.role), role: stringArg(values.role),
@ -207,6 +205,13 @@ async function main() {
result = await showAgent({ agent: stringArg(values.agent)! }); result = await showAgent({ agent: stringArg(values.agent)! });
break; break;
case 'activity-lease':
if (!values.agent) throw new Error('--agent required');
result = await extendActivityLease({
agent: stringArg(values.agent)!,
}, deps);
break;
// --- Mail --- // --- Mail ---
case 'send': case 'send':
if (!values.from || !values.to || !values.bead || !values.category || !values.subject || !values.body) { if (!values.from || !values.to || !values.bead || !values.category || !values.subject || !values.body) {