feat(protocol): pivot to Passive Heartbeat for Windows stability
The previous background loop approach was disruptive on Windows (terminal pop-ups). We collaborated to find a more robust, silent alternative: - Removed all background heartbeat worker logic and PID management. - Implemented 'Passive Heartbeat' in tools/bb.ts: every agent command now refreshes liveness as a side-effect. - Updated bb-init.mjs to use explicit heartbeat calls for adoption/registration. - Result is 100% silent observability: if an agent is working, they are Active. If they stop, they drift to Stale. OPERATIVE: silver-castle SESSION: 2026-02-14-1300
This commit is contained in:
parent
965d11c0b9
commit
5b9c0aa6a3
4 changed files with 222 additions and 3 deletions
File diff suppressed because one or more lines are too long
122
scripts/bb-init.mjs
Normal file
122
scripts/bb-init.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* bb-init.mjs - Agent Session Bootstrapper (Passive Version)
|
||||
*
|
||||
* Part of Operative Protocol v1 (bb-u6f.6.3)
|
||||
*
|
||||
* Responsibility:
|
||||
* 1. Resolve bb.ps1 path.
|
||||
* 2. Identify agent (adopt or register).
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
function log(obj) {
|
||||
process.stdout.write(`${JSON.stringify(obj, null, 2)}
|
||||
`);
|
||||
}
|
||||
|
||||
function error(code, message) {
|
||||
log({ ok: false, error: { code, message } });
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function getUncommittedChanges(projectRoot) {
|
||||
try {
|
||||
const out = execSync('git status --porcelain', { cwd: projectRoot, encoding: 'utf8' });
|
||||
return out.split('\n')
|
||||
.filter(Boolean)
|
||||
.map(line => line.slice(3).trim())
|
||||
.filter(p => !p.startsWith('.beadboard') && !p.startsWith('.beads'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBbPath() {
|
||||
const envRepo = process.env.BB_REPO;
|
||||
if (envRepo) {
|
||||
const p = path.join(envRepo, 'bb.ps1');
|
||||
try {
|
||||
await fs.access(p);
|
||||
return p;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const local = path.join(process.cwd(), 'bb.ps1');
|
||||
try {
|
||||
await fs.access(local);
|
||||
return local;
|
||||
} catch {}
|
||||
|
||||
const tsEntry = path.join(process.cwd(), 'tools', 'bb.ts');
|
||||
try {
|
||||
await fs.access(tsEntry);
|
||||
return `npx tsx ${tsEntry}`;
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
'non-interactive': { type: 'boolean' },
|
||||
adopt: { type: 'string' },
|
||||
register: { type: 'string' },
|
||||
role: { type: 'string' },
|
||||
json: { type: 'boolean' }
|
||||
}
|
||||
});
|
||||
|
||||
const isNonInteractive = values['non-interactive'];
|
||||
const projectRoot = process.cwd();
|
||||
const bbPath = await resolveBbPath();
|
||||
|
||||
if (!bbPath) {
|
||||
error('BB_NOT_FOUND', 'Could not resolve bb.ps1 or tools/bb.ts');
|
||||
}
|
||||
|
||||
let agentId = values.adopt || values.register;
|
||||
let mode = values.adopt ? 'adopt' : (values.register ? 'register' : 'auto');
|
||||
|
||||
if (mode === 'auto' && isNonInteractive) {
|
||||
error('AMBIGUOUS_SESSION', 'In non-interactive mode, --adopt or --register is required.');
|
||||
}
|
||||
|
||||
if (mode === 'adopt') {
|
||||
const changes = await getUncommittedChanges(projectRoot);
|
||||
if (changes.length === 0 && isNonInteractive) {
|
||||
error('ADOPTION_REJECTED', 'No evidence (uncommitted changes) to support identity adoption.');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const bbExec = bbPath.includes('npx tsx') ? bbPath : `powershell.exe -NoProfile -Command "& '${bbPath}'"`;
|
||||
|
||||
if (mode === 'register') {
|
||||
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' });
|
||||
}
|
||||
|
||||
log({
|
||||
ok: true,
|
||||
agent_id: agentId,
|
||||
mode,
|
||||
heartbeat: { status: 'passive', note: 'Heartbeat managed via passive command side-effects' },
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
error('INIT_FAILED', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
83
tests/scripts/bb-init.test.ts
Normal file
83
tests/scripts/bb-init.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execSync } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '../../');
|
||||
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-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
// Initialize a fake git repo
|
||||
execSync('git init', { cwd: tempDir, stdio: 'ignore' });
|
||||
await fs.writeFile(path.join(tempDir, 'dummy'), 'data');
|
||||
execSync('git add dummy && git commit -m "initial"', { cwd: tempDir, stdio: 'ignore' });
|
||||
|
||||
try {
|
||||
await run(tempDir);
|
||||
} finally {
|
||||
process.env.USERPROFILE = previous;
|
||||
await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
test('PASSIVE: bb-init --register updates liveness via side-effect', async (t) => {
|
||||
await withTempRegistry(async (tempDir) => {
|
||||
const agentId = 'passive-agent';
|
||||
const cmd = `node ${initScript} --register ${agentId} --role backend --json`;
|
||||
|
||||
const out = execSync(cmd, {
|
||||
cwd: tempDir,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, BB_REPO: projectRoot }
|
||||
});
|
||||
const result = JSON.parse(out);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.heartbeat.status, 'passive');
|
||||
|
||||
// Verify Registry Entry exists and has a timestamp
|
||||
const agentFile = path.join(tempDir, '.beadboard', 'agent', 'agents', `${agentId}.json`);
|
||||
const agentData = JSON.parse(await fs.readFile(agentFile, 'utf8'));
|
||||
assert.equal(agentData.agent_id, agentId);
|
||||
assert.ok(agentData.last_seen_at);
|
||||
});
|
||||
});
|
||||
|
||||
test('PASSIVE: bb-init --adopt rejection still works with noise filtering', async (t) => {
|
||||
await withTempRegistry(async (tempDir) => {
|
||||
const agentId = 'noise-agent';
|
||||
|
||||
// Register first
|
||||
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');
|
||||
}
|
||||
|
||||
// Accepts with real change
|
||||
await fs.writeFile(path.join(tempDir, 'real.ts'), 'code');
|
||||
const adoptOut = execSync(`node ${initScript} --adopt ${agentId} --non-interactive --json`, {
|
||||
cwd: tempDir,
|
||||
env: { ...process.env, BB_REPO: projectRoot }
|
||||
});
|
||||
assert.equal(JSON.parse(adoptOut).ok, true);
|
||||
});
|
||||
});
|
||||
16
tools/bb.ts
16
tools/bb.ts
|
|
@ -1,6 +1,6 @@
|
|||
import { parseArgs } from 'node:util';
|
||||
import {
|
||||
registerAgent, listAgents, showAgent, type AgentCommandResponse
|
||||
registerAgent, listAgents, showAgent, heartbeatAgent, type AgentCommandResponse
|
||||
} from '../src/lib/agent-registry';
|
||||
import {
|
||||
sendAgentMessage, inboxAgentMessages, readAgentMessage, ackAgentMessage,
|
||||
|
|
@ -169,6 +169,13 @@ 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.
|
||||
const targetAgent = stringArg(values.agent) || stringArg(values.from) || stringArg(values.name);
|
||||
if (targetAgent && command !== 'register') {
|
||||
await heartbeatAgent({ agent: targetAgent }, deps).catch(() => {});
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
// --- Identity ---
|
||||
case 'register':
|
||||
|
|
@ -181,6 +188,13 @@ 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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue