feat(protocol): implement core backend engine for Operative Protocol
Our collaboration led to a rigorous 'Session Constitution' where we prioritized observability and concurrency safety. I've delivered the first four pillars of the backend engine: 1. Liveness Registry: Heartbeat logic and derivation of active/stale/evicted states based on the 15m threshold. 2. Overlap Classifier: Canonical path normalization (Windows-aware) and exact/partial overlap detection. 3. Takeover Rules: Enforced discipline where active agents are protected, while stale/evicted ones can be overtaken via --takeover-stale. 4. Protocol Schema: Establishing the v1 envelope for high-fidelity agent signaling. TDD was applied throughout, with 100% pass rate on the new liveness, overlap, takeover, and protocol tests.
This commit is contained in:
parent
1ae7efb31b
commit
41f7cb8f24
8 changed files with 468 additions and 14 deletions
99
tests/lib/agent-liveness.test.ts
Normal file
99
tests/lib/agent-liveness.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
registerAgent,
|
||||
heartbeatAgent,
|
||||
deriveLiveness,
|
||||
agentFilePath,
|
||||
} from '../../src/lib/agent-registry';
|
||||
|
||||
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-liveness-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('heartbeatAgent 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';
|
||||
|
||||
await registerAgent(
|
||||
{ name: 'active-agent', role: 'infra' },
|
||||
{ now: () => start }
|
||||
);
|
||||
|
||||
const result = await heartbeatAgent(
|
||||
{ agent: 'active-agent' },
|
||||
{ now: () => next }
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.data?.last_seen_at, next);
|
||||
assert.equal(result.data?.version, 2);
|
||||
|
||||
const raw = await fs.readFile(agentFilePath('active-agent'), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
assert.equal(parsed.last_seen_at, next);
|
||||
});
|
||||
});
|
||||
|
||||
test('deriveLiveness follows threshold rules (15m/30m default)', () => {
|
||||
const now = new Date('2026-02-14T12:00:00Z');
|
||||
|
||||
// Active: 14 mins ago
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T11:46:00Z', now),
|
||||
'active'
|
||||
);
|
||||
|
||||
// Stale: Exactly 15 mins ago
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T11:45:00Z', now),
|
||||
'stale'
|
||||
);
|
||||
|
||||
// Stale: 29 mins ago
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T11:31:00Z', now),
|
||||
'stale'
|
||||
);
|
||||
|
||||
// Evicted: Exactly 30 mins ago
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T11:30:00Z', now),
|
||||
'evicted'
|
||||
);
|
||||
|
||||
// Evicted: 1 hour ago
|
||||
assert.equal(
|
||||
deriveLiveness('2026-02-14T11:00:00Z', now),
|
||||
'evicted'
|
||||
);
|
||||
});
|
||||
|
||||
test('deriveLiveness respects custom staleMinutes', () => {
|
||||
const now = new Date('2026-02-14T12:00:00Z');
|
||||
const customThreshold = 5; // 5m stale, 10m evicted
|
||||
|
||||
assert.equal(deriveLiveness('2026-02-14T11:56:00Z', now, customThreshold), 'active');
|
||||
assert.equal(deriveLiveness('2026-02-14T11:55:00Z', now, customThreshold), 'stale');
|
||||
assert.equal(deriveLiveness('2026-02-14T11:51:00Z', now, customThreshold), 'stale');
|
||||
assert.equal(deriveLiveness('2026-02-14T11:50:00Z', now, customThreshold), 'evicted');
|
||||
});
|
||||
29
tests/lib/agent-protocol.test.ts
Normal file
29
tests/lib/agent-protocol.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createProtocolEvent,
|
||||
type ProtocolEvent
|
||||
} from '../../src/lib/agent-protocol';
|
||||
|
||||
test('createProtocolEvent generates a valid v1 envelope', () => {
|
||||
const event = createProtocolEvent({
|
||||
event_type: 'HANDOFF',
|
||||
project_root: '/work/project',
|
||||
bead_id: 'bb-123',
|
||||
from_agent: 'agent-a',
|
||||
to_agent: 'agent-b',
|
||||
scope: 'src/lib/*',
|
||||
payload: {
|
||||
subject: 'Ready for review',
|
||||
summary: 'Implemented feature X',
|
||||
next_action: 'Please run tests',
|
||||
requires_ack: true
|
||||
}
|
||||
}, { now: () => '2026-02-14T10:00:00.000Z', idGenerator: () => 'proto_1' });
|
||||
|
||||
assert.equal(event.version, 'v1');
|
||||
assert.equal(event.event_type, 'HANDOFF');
|
||||
assert.equal(event.id, 'proto_1');
|
||||
assert.equal(event.created_at, '2026-02-14T10:00:00.000Z');
|
||||
assert.equal(event.payload.subject, 'Ready for review');
|
||||
});
|
||||
91
tests/lib/agent-takeover.test.ts
Normal file
91
tests/lib/agent-takeover.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { registerAgent, heartbeatAgent } from '../../src/lib/agent-registry';
|
||||
import { reserveAgentScope } from '../../src/lib/agent-reservations';
|
||||
|
||||
async function withTempUserProfile(run: () => Promise<void>): Promise<void> {
|
||||
const previous = process.env.USERPROFILE;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-agent-takeover-'));
|
||||
process.env.USERPROFILE = tempDir;
|
||||
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previous;
|
||||
}
|
||||
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('takeover rules based on owner liveness', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
// T=0: Register Owner
|
||||
const t0 = '2026-02-14T10:00:00.000Z';
|
||||
await registerAgent({ name: 'owner', role: 'infra' }, { now: () => t0 });
|
||||
|
||||
// T=1: Owner reserves scope
|
||||
const t1 = '2026-02-14T10:01:00.000Z';
|
||||
await reserveAgentScope(
|
||||
{ agent: 'owner', scope: 'src/lib', bead: 'bb-1' },
|
||||
{ now: () => t1 }
|
||||
);
|
||||
|
||||
// T=2: Invader tries takeover while Owner is ACTIVE (1 min since last seen)
|
||||
const t2 = '2026-02-14T10:02:00.000Z';
|
||||
await registerAgent({ name: 'invader', role: 'infra' }, { now: () => t2 });
|
||||
|
||||
const activeTakeover = await reserveAgentScope(
|
||||
{ agent: 'invader', scope: 'src/lib', bead: 'bb-2', takeoverStale: true },
|
||||
{ now: () => t2 }
|
||||
);
|
||||
assert.equal(activeTakeover.ok, false);
|
||||
assert.equal(activeTakeover.error?.code, 'RESERVATION_CONFLICT');
|
||||
assert.match(activeTakeover.error?.message || '', /already reserved by.*owner/);
|
||||
|
||||
// T=17: Owner is now STALE (16 mins since last seen at T=1)
|
||||
const t17 = '2026-02-14T10:17:00.000Z';
|
||||
|
||||
// Takeover without flag fails
|
||||
const staleNoFlag = await reserveAgentScope(
|
||||
{ agent: 'invader', scope: 'src/lib', bead: 'bb-2', takeoverStale: false },
|
||||
{ now: () => t17 }
|
||||
);
|
||||
assert.equal(staleNoFlag.ok, false);
|
||||
assert.equal(staleNoFlag.error?.code, 'RESERVATION_STALE_FOUND');
|
||||
|
||||
// Takeover with flag succeeds
|
||||
const staleWithFlag = await reserveAgentScope(
|
||||
{ agent: 'invader', scope: 'src/lib', bead: 'bb-2', takeoverStale: true },
|
||||
{ now: () => t17 }
|
||||
);
|
||||
assert.equal(staleWithFlag.ok, true);
|
||||
assert.equal(staleWithFlag.data?.agent_id, 'invader');
|
||||
});
|
||||
});
|
||||
|
||||
test('takeover succeeds when owner is EVICTED', async () => {
|
||||
await withTempUserProfile(async () => {
|
||||
const t0 = '2026-02-14T10:00:00.000Z';
|
||||
await registerAgent({ name: 'owner', role: 'infra' }, { now: () => t0 });
|
||||
await reserveAgentScope({ agent: 'owner', scope: 'src/lib', bead: 'bb-1' }, { now: () => t0 });
|
||||
|
||||
// T=31: Owner is EVICTED (31 mins since last seen)
|
||||
const t31 = '2026-02-14T10:31:00.000Z';
|
||||
await registerAgent({ name: 'invader', role: 'infra' }, { now: () => t31 });
|
||||
|
||||
const evictedTakeover = await reserveAgentScope(
|
||||
{ agent: 'invader', scope: 'src/lib', bead: 'bb-2', takeoverStale: true },
|
||||
{ now: () => t31 }
|
||||
);
|
||||
assert.equal(evictedTakeover.ok, true);
|
||||
assert.equal(evictedTakeover.data?.agent_id, 'invader');
|
||||
});
|
||||
});
|
||||
45
tests/lib/path-overlap.test.ts
Normal file
45
tests/lib/path-overlap.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
|
||||
// We'll export these from agent-reservations.ts
|
||||
import {
|
||||
normalizePath,
|
||||
classifyOverlap
|
||||
} from '../../src/lib/agent-reservations';
|
||||
|
||||
test('normalizePath canonicalizes various path formats', () => {
|
||||
const root = path.resolve('/');
|
||||
|
||||
// Basic normalization
|
||||
assert.equal(normalizePath('src/lib/'), normalizePath('src/lib'));
|
||||
assert.equal(normalizePath('src//lib'), normalizePath('src/lib'));
|
||||
|
||||
// Windows specific (if running on windows, this is handled in the impl)
|
||||
// We can't easily test cross-platform logic without mocking path.
|
||||
// But we can check that it resolves and removes trailing slash.
|
||||
const p1 = normalizePath('src/components');
|
||||
assert.ok(path.isAbsolute(p1));
|
||||
assert.ok(!p1.endsWith('/') || p1 === root);
|
||||
});
|
||||
|
||||
test('classifyOverlap correctly identifies exact matches', () => {
|
||||
const p1 = 'src/lib/parser.ts';
|
||||
const p2 = 'src/lib/parser.ts';
|
||||
assert.equal(classifyOverlap(p1, p2), 'exact');
|
||||
});
|
||||
|
||||
test('classifyOverlap correctly identifies partial overlaps', () => {
|
||||
// Parent-child
|
||||
assert.equal(classifyOverlap('src/lib', 'src/lib/parser.ts'), 'partial');
|
||||
assert.equal(classifyOverlap('src/lib/parser.ts', 'src/lib'), 'partial');
|
||||
|
||||
// Prefix/Wildcard
|
||||
assert.equal(classifyOverlap('src/*', 'src/lib/parser.ts'), 'partial');
|
||||
assert.equal(classifyOverlap('src/lib/parser.ts', 'src/*'), 'partial');
|
||||
});
|
||||
|
||||
test('classifyOverlap correctly identifies disjoint paths', () => {
|
||||
assert.equal(classifyOverlap('src/lib', 'src/components'), 'disjoint');
|
||||
assert.equal(classifyOverlap('src/lib/parser.ts', 'src/lib/other.ts'), 'disjoint');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue