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:
parent
5b9c0aa6a3
commit
e010e0b10b
6 changed files with 69 additions and 59 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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()
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
27
tools/bb.ts
27
tools/bb.ts
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue