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
|
#!/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();
|
||||||
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
35
tools/bb.ts
35
tools/bb.ts
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue