mirror of
https://github.com/block/buzz.git
synced 2026-11-05 17:01:35 +00:00
feat(beehive): recover source-consumed fixture Move transaction
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -5,6 +5,92 @@ Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; continuation starts at
|
||||
`023c9274767ef50fa0f5b37ef1883336f8be59fd`. Candidate is the commit containing
|
||||
this checkpoint (`git rev-parse HEAD`). No merge/release.
|
||||
|
||||
## RECOVERED 9ff7 — executable fixture transaction, partial product handoff
|
||||
|
||||
Recovered actual HEAD and actual remote both at
|
||||
`8ba5fa9cd474aab5ce48fa255b194f8d3173aec1`, with no prior transaction commit/push.
|
||||
Preserved dirty checkpoint/cli/host/intents/protocol and untracked handoff/move test.
|
||||
A RECOVERED entry was written before implementation or long validation. The commit
|
||||
containing this section is the new executable candidate (publication recorded in the
|
||||
terminal result). Historical sections below are superseded by this current state.
|
||||
|
||||
**Recovered:** source reservation, target preparation, source Stop, durable one-way
|
||||
consumed authority + exact grant/outbox, validated predecessor chain, idempotent target
|
||||
accept/fresh Start, TUI Move intent, three initial tests. **New:** validated Stop/Save
|
||||
before cancelling Move; exact pending Move retry republishes prepare rather than a
|
||||
terminal interrupted receipt; non-fixture/conversation Move explicitly refused before
|
||||
source effects because metadata preparation is not exact-model ready. Shared status,
|
||||
rows and retry confirmations now identify host + full agent + operation; Move includes
|
||||
endpoints. Source Start never resumes after consumption, including service restart.
|
||||
No source-validator relaxation without successor chain; no authority rollback.
|
||||
|
||||
Observable tests: actual two-host subprocess + actual TUI Move X leaves source Y's
|
||||
journal byte-identical; standby denied before grant; wrong target revision preserves
|
||||
running source; reverse successor rejects historical grant. Fault-injected relay
|
||||
loses grant and source receipt before receipt storage; inspect recovers outbox once.
|
||||
Target key deletion and prepared setup change after grant both leave target assigned
|
||||
stopped and source irrevocably denied, including after source service restart. Delayed
|
||||
prepared replies cover exact pending retry, valid Stop/Save cancellation, malformed
|
||||
Stop/unsupported Save inertness and late Start denial. Target admission failure here
|
||||
is not a claim of a live-provider launch failure or moved conversation response.
|
||||
|
||||
Consumed `ASSIGNMENT_SLOTS_REVIEW_8BA5FA9C.md`: F1 fixed, actual reopened two-agent
|
||||
same-host/action/revision blocked-operation TUI regression checks each row and retry
|
||||
confirmation. Declining either sends neither; immutable intent/receipt fences retained.
|
||||
Consumed `RECOVERY_VERIFICATION_8A67B967.md` findings remain closed, not pending.
|
||||
|
||||
Consumed new `DESCENDANT_EXIT_EBC8B3CB.md` report during this run. It reproduces the
|
||||
invalid fixture PID failure signature and reports group/member teardown probes green;
|
||||
historical raw PID/errno were not captured, so invalid PID versus PID reuse remains
|
||||
unproved. **Containment gate remains OPEN, not cleared by these runs.** At the exact
|
||||
reply-tool absence assertion, added raw PID, errno, spawn/error provenance and bounded
|
||||
per-PID state diagnostics on recurrence, retaining ESRCH requirement and no extra
|
||||
sleep. No owned.ts/supervisor teardown change or reconstructed PID kill authority.
|
||||
Existing live anchors remain the only teardown authority; stronger full-lifetime
|
||||
provenance and the historical observation still require reconciliation. Do not call
|
||||
this preview safe Move or an exactly-once/partition-safe transfer.
|
||||
|
||||
Validation evidence: `WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/MOVE_9FF7_EVIDENCE/`.
|
||||
Recovered strict + Move **3/3** passed; new targeted TUI/Move **5/5** passed, expanded
|
||||
Move **4/4** passed. First candidate full installed-enabled **30/30**, zero skips,
|
||||
19.90s (`check.log`, `full.log`). Late self-review then corrected pending Move retry
|
||||
semantics; targeted **4/4** passed (`pending-retry.log`). Final-source strict/full
|
||||
**30/30 pass, zero skips**, 19.12s (`check-final.log`, `full-final.log`). No failing executable run occurred in this
|
||||
recovery; earlier worker test history is unknown. Initial discovery directory scan
|
||||
timed out; no state mutation. Prior historical failing suite remains documented below.
|
||||
Installed binary hashes are separate from source identity; both freshly match the
|
||||
retained pins (`installed-binaries.sha256`).
|
||||
|
||||
**ONE next executable action:** run the instrumented installed-enabled full suite at
|
||||
this immutable candidate under the original contention configuration, retaining raw
|
||||
failure capture if it recurs, to reconcile the still-open source-exit observation
|
||||
before promoting fixture Move or implementing exact-ready conversation/provider Move.
|
||||
Do not repeat resolved slots-label or R1/U1 reviews. Broader gate also needs independent
|
||||
review of this new authority transaction; no installed moved-identity reply attempted.
|
||||
|
||||
Scope: dedicated trusted non-cloned/non-rollback installations/exclusive supervision;
|
||||
shared-owner signatures bind trusted protocol traffic, not physical host attestation.
|
||||
Root/assignment persists stopped. TUI sends intents only; hosts share existing
|
||||
management relay. No keys/workspaces/sessions/credentials transferred or regenerated,
|
||||
no unreachable-source takeover, new controller/registry/HTTP route, provider/login,
|
||||
production kind-40002/admission/OAuth proof, Rust/native or owner-store access.
|
||||
README parity matrix retains profiles/key CRUD/Restart/harness/provider/preset/mesh/
|
||||
compute/real UI/live acceptance. No repo-wide CI, PR, merge or release claimed.
|
||||
|
||||
## Move continuation 84e0 — implementation in progress
|
||||
|
||||
Starting exact published `8ba5fa9cd474aab5ce48fa255b194f8d3173aec1`.
|
||||
Implementing per-slot durable reservation → target prepared → source exit →
|
||||
outgoing-consumed (grant and outbox in one journal write) → incoming-accepted →
|
||||
fresh ordinary Start. Genesis stays pinned; only prefix-extending validated grant
|
||||
lineage can replace initial-host authority. Preparation is not authority. Save/Stop
|
||||
invalidate an outstanding source reservation; post-grant failures never undo it.
|
||||
Next code path: host slot relay exchanges and immutable local preparation digest,
|
||||
then actual two-host execution tests and TUI destination selection.
|
||||
The descendant-exit report is not yet present at its supplied public path. Existing
|
||||
25/25 is not resolution; final safe/conversation Move acceptance remains gated on
|
||||
that investigation and causal regression. No containment changes in this stage.
|
||||
|
||||
## Slot continuation e0a5 — working single-installation slice
|
||||
|
||||
Started from verified published `ebc8b3cb24fe0eefe5b8f239a88555786eadbf14`.
|
||||
|
||||
@@ -96,7 +96,7 @@ arbitrary Nostr kind is not an admission solution.
|
||||
One dedicated owner shares signing/decryption authority with fully trusted host
|
||||
installations. Host IDs route messages; they are NOT cryptographic host proof.
|
||||
Agent identities are generated separately and only locally. Standby key import
|
||||
attaches to an existing public genesis; Move remains disabled. Setup refuses an existing host directory; saved journal
|
||||
attaches to an existing public genesis; only explicit experimental fixture Move can transfer assignment. Setup refuses an existing host directory; saved journal
|
||||
bindings prevent owner/agent/host replacement from resetting admission.
|
||||
An owner can still bypass software by cloning/recreating installations: safety
|
||||
assumes non-cloned, non-rollback, exclusively supervised installations.
|
||||
@@ -175,11 +175,10 @@ unreachable source never makes its standby eligible. The original assignment tes
|
||||
`slots.test.ts` separately proves two identities in ONE host process/connection;
|
||||
these are independent agent slots, not cloned daemons.
|
||||
|
||||
**Not yet implemented:** destination preflight, source reservation/whole-tree Stop
|
||||
as a transfer, irreversible grant/outbox lineage, target acceptance/fresh launch,
|
||||
and source-consumed Move lineage. No `move` command
|
||||
is exposed until these boundaries are implemented and tested together. The root
|
||||
ID is an identity binding, not a lock service or partition/rollback solution.
|
||||
**Experimental fixture path implemented below:** destination local preflight, source
|
||||
reservation/Stop, consumed grant/outbox lineage and target acceptance/fresh launch.
|
||||
Safe-exit acceptance and provider exact-ready preflight remain gated. The root ID
|
||||
is an identity binding, not a lock service or partition/rollback solution.
|
||||
|
||||
## Several agents, one installation
|
||||
|
||||
@@ -221,8 +220,8 @@ failure and retains the installation lock rather than falsely declaring all stop
|
||||
|
||||
Setup/key enrollment remains local and offline; ordinary selections and lifecycle
|
||||
remain remote. Multiple selectable harness inventory entries, profile CRUD, key
|
||||
revocation UX, Restart and Move are still future work. Standby X elsewhere still
|
||||
cannot Start, even while source X is stopped/unreachable. This is trusted-installation
|
||||
revocation UX, Restart and provider/conversation Move are still future work. Standby
|
||||
X elsewhere cannot Start without a grant, even while source X is stopped/unreachable. This is trusted-installation
|
||||
coordination, not physical host attestation, partition safety or exactly-once execution.
|
||||
|
||||
## Real target: Buzz Agent + Databricks v2
|
||||
@@ -337,7 +336,7 @@ remain explicit local/operator actions. No live Databricks request has occurred.
|
||||
| Local owner/key setup | New owner + separately generated agent key | nsec import, saved-owner verification, revocation/key removal UX |
|
||||
| Host inventory | Real relay, multiple independent slots, shared host harness, freshness | multiple selectable setups, service install, richer reconciliation |
|
||||
| Remote configuration | CAS model/workspace/default profile selection | reusable profiles, metadata, config history, setup revision pinning |
|
||||
| Lifecycle | fixture Start/Stop, UI-independent host | strict real launch, Restart, host-owned irreversible Move |
|
||||
| Lifecycle | fixture Start/Stop and experimental source-consumed Move, UI-independent host | containment acceptance, exact-ready provider Move, Restart |
|
||||
| Buzz Agent Databricks v2 | Local setup + TypeScript ACP probe boundary | live OAuth/model proof, production protocol compatibility and catalog provenance |
|
||||
| Other Buzz Agent providers | Not implemented | Anthropic, OpenAI-compatible, Databricks legacy, OpenRouter |
|
||||
| Goose / Claude Code / Codex | Not implemented | harness-specific local auth and adapters/catalog/launch |
|
||||
@@ -348,3 +347,29 @@ remain explicit local/operator actions. No live Databricks request has occurred.
|
||||
|
||||
None of the remaining rows is an owner-approved exclusion from the requested
|
||||
full parity. Review actual executable UX/security/lifecycle before expanding.
|
||||
|
||||
## Experimental fixture Move (recovered continuation)
|
||||
|
||||
On two separately provisioned, trusted installations with the SAME agent key and
|
||||
pinned public genesis, use `hosts`, select the source agent row, then `move` and
|
||||
confirm the destination. Hosts exchange preparation/grants over the existing relay;
|
||||
the TUI sends intent only. Target selection uses its locally configured allowed
|
||||
model/workspace/profile. No key, workspace, credential or conversation session moves.
|
||||
The source receipt means **source consumed + grant durably queued**, NOT destination
|
||||
process exit/start evidence. Observe target inventory separately.
|
||||
|
||||
The source retains an irreversible grant chain/outbox after Stop. A destination
|
||||
accepts an exact successor once before fresh Start; missing/changed prepared inputs
|
||||
leave it assigned but stopped. Retry/inspect replays the grant without restoring the
|
||||
source. A stopped or unreachable source does not authorize a standby by itself.
|
||||
The experimental path is restricted in code to fixture harnesses: provider/catalog
|
||||
metadata is not exact-model readiness, and conversation/provider Move is refused
|
||||
before source effects. Ordinary Start retains same-session exact-model enforcement.
|
||||
|
||||
**Containment acceptance remains OPEN.** The historical descendant-PID failure is
|
||||
not classified between invalid fixture PID and PID reuse. Recovered probe evidence
|
||||
supports the group teardown contract but does not close that historical gate; the
|
||||
original absence requirement now records discriminating failure diagnostics. No
|
||||
safe Move, exactly-once, partition-safe or moved-identity conversation claim.
|
||||
Trust requires dedicated non-cloned/non-rollback installations, exclusive supervision
|
||||
and local provisioning; shared owner signatures do not attest physical hosts.
|
||||
|
||||
@@ -194,3 +194,25 @@ positions amid coalesced async status reports. A subsequent new reconciliation t
|
||||
exposed the initial-history hydration gap above, fixed at the host connection owner.
|
||||
No containment assertion, delay or existing test was weakened. The historical
|
||||
reply-tool descendant-exit observation remains a separate open investigation.
|
||||
|
||||
## Recovered fixture Move and recovery labels
|
||||
|
||||
`move.test.ts` executes two real host subprocesses plus actual TUI and relay: Move X,
|
||||
Y byte-identical, standby Start denied, wrong target revision preserves running source,
|
||||
reverse successor and historical grant replay. Fault-injected real host/relay tests
|
||||
lose grant and source receipt before receipt storage, replay outbox, remove target key
|
||||
or change prepared setup, and verify assigned/stopped target and revoked source even
|
||||
after source service restart. Delayed prepared replies exercise valid Stop/Save
|
||||
cancellation, invalid Stop/Save inertness and late Start fencing. These are fixture
|
||||
launch/admission tests, not provider readiness or destination conversation replies.
|
||||
|
||||
`policy-tui.test.ts` reopens two same-host/action/revision blocked operations and checks
|
||||
both agent identities and operation IDs in numbered rows AND each retry confirmation;
|
||||
declining sends neither. Existing immutable-envelope policy-retry tests remain.
|
||||
|
||||
Consumed `DESCENDANT_EXIT_EBC8B3CB.md`: exact historical cause remains unproved
|
||||
(invalid fixture PID vs reuse); the group probes are evidence, not safe-Move approval.
|
||||
`reply-tool.test.ts` now captures raw PID, actual probe errno, fixture spawn/error
|
||||
provenance, and bounded per-PID process state on failure. It does not wait longer or
|
||||
weaken ESRCH, and does not kill any PID obtained from observations. Full-suite green
|
||||
results without recurrence do not classify the historical failure.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { readPrivate, writePrivate } from './storage.ts';
|
||||
import { createGenesis, validateGenesis } from './assignment.ts';
|
||||
import { prepareConversation } from './conversation.ts';
|
||||
|
||||
const operationLabel = (m: Message) => `${m.host} ${m.type}${m.type === 'move' ? ` → ${String(m.body.target)}` : ''} | agent ${m.agent} | operation ${m.id}`;
|
||||
const shellQuote = (s: string) => `'${s.replaceAll("'", "'\"'\"'")}'`;
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
const help = `Beehive — isolated development preview (loopback relay only)
|
||||
@@ -27,7 +28,7 @@ const help = `Beehive — isolated development preview (loopback relay only)
|
||||
tui <identity-file> <ws://127.0.0.1:port> Relay-connected terminal UI
|
||||
setup/add-agent accept optional <local-key-file> <public-genesis-file> for standby import.
|
||||
assignment-export accepts agent public key after filename when several slots exist.
|
||||
No Move, provider login RPC or production relay support yet.`;
|
||||
Move is fixture-only experimental; containment acceptance remains gated. No provider login RPC or production relay support.`;
|
||||
async function main() {
|
||||
if (command === 'identity') {
|
||||
const dir = resolve(text(args[0]));
|
||||
@@ -144,18 +145,18 @@ async function main() {
|
||||
}
|
||||
}, () => {
|
||||
console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; automatic reconnect is bounded (disabled on policy refusal). Use reconcile after checking relay policy.');
|
||||
for (const operation of client.status()) console.log(`${operation.request.host} ${operation.request.type}: ${operation.state} | ${operation.result ?? operation.publication}`);
|
||||
for (const operation of client.status()) console.log(`${operationLabel(operation.request)}: ${operation.state} | ${operation.result ?? operation.publication}`);
|
||||
});
|
||||
try { await client.ready; } catch (error) { client.close(); throw error; }
|
||||
const ui = createInterface({ input: stdin, output: stdout });
|
||||
console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: operations, reconcile, retry <number>, hosts, agents, select <number or unique host>, show, save, start, stop, quit. Closing this UI does not stop hosts.');
|
||||
console.log('Beehive | Hosts → assigned agent → selected-next / actual run\nCommands: operations, reconcile, retry <number>, hosts, agents, select <number or unique host>, show, save, start, stop, move, quit. Closing this UI does not stop hosts.');
|
||||
let selected = '';
|
||||
try {
|
||||
for (;;) {
|
||||
const line = (await ui.question('beehive> ')).trim();
|
||||
if (line === 'quit') break;
|
||||
if (line === 'operations') {
|
||||
client.status().forEach((o, index) => console.log(`${index + 1}. ${o.request.host} ${o.request.type} at revision ${o.request.revision}: ${o.state} | ${o.result ?? o.publication}${o.retryAvailable ? ` | reconcile, then retry ${index + 1}` : ''}`));
|
||||
client.status().forEach((o, index) => console.log(`${index + 1}. ${operationLabel(o.request)} at revision ${o.request.revision}: ${o.state} | ${o.result ?? o.publication}${o.retryAvailable ? ` | reconcile, then retry ${index + 1}` : ''}`));
|
||||
console.log('Historical results are not current host state. Reconcile queries receipts without retrying blocked work.'); continue;
|
||||
}
|
||||
if (line === 'reconcile') { client.reconcile(); console.log('Reconnecting/querying host results; blocked work stays blocked. Connection alone proves neither policy repair nor completion.'); continue; }
|
||||
@@ -163,7 +164,7 @@ async function main() {
|
||||
const number = line.slice(6);
|
||||
const operation = /^[1-9][0-9]*$/.test(number) ? client.status()[Number(number) - 1] : undefined;
|
||||
if (!operation?.retryAvailable) { console.log('Use operations to choose an unresolved policy-blocked operation.'); continue; }
|
||||
console.log(`Retry original ${operation.request.host} ${operation.request.type} at revision ${operation.request.revision} ONCE, with unchanged signature, ID and preconditions. It may already have executed. Reconcile first. Repair relay policy before retry; expired/invalid signed events cannot be repaired here. This does not cancel remote work or enable automatic retries.`);
|
||||
console.log(`Retry original ${operationLabel(operation.request)} at revision ${operation.request.revision} ONCE, with unchanged signature, ID and preconditions. It may already have executed. Reconcile first. Repair relay policy before retry; expired/invalid signed events cannot be repaired here. This does not cancel remote work or enable automatic retries.`);
|
||||
if ((await ui.question('Attempt unchanged operation after policy repair? [yes/no]: ')) === 'yes') {
|
||||
try { client.retry(operation.request.id); console.log('Unchanged retry attempted; result UNKNOWN until host receipt. Automatic retry stays disabled.'); }
|
||||
catch (error) { console.log(error instanceof Error ? error.message : 'Retry not sent'); }
|
||||
@@ -186,16 +187,24 @@ async function main() {
|
||||
const current = inventory.get(selected);
|
||||
if (!current) { console.log('Select an advertised host first.'); continue; }
|
||||
if (line === 'show') { console.log(JSON.stringify(current,null,2)); continue; }
|
||||
if (!['save','start','stop'].includes(line)) { console.log('Use operations/reconcile/retry <number>/hosts/select/show/save/start/stop/quit.'); continue; }
|
||||
if (!['save','start','stop','move'].includes(line)) { console.log('Use operations/reconcile/retry <number>/hosts/select/show/save/start/stop/move/quit.'); continue; }
|
||||
if (Date.now()-Number(current.body.observedAt) > 6000) { console.log('Host stale: status unknown; no action sent.'); continue; }
|
||||
let body: Record<string, unknown> = {};
|
||||
if (line === 'save') {
|
||||
console.log(`Allowed models: ${JSON.stringify(current.body.models)}; workspaces: ${JSON.stringify(current.body.workspaces)}; behavior profiles: ${JSON.stringify(current.body.profiles)}`);
|
||||
body = { model: await ui.question('Model: '), workspace: await ui.question('Workspace: '), profile: await ui.question('Behavior profile: ') };
|
||||
}
|
||||
const request = message(line as 'save' | 'start' | 'stop',current.host,current.agent,current.revision,body);
|
||||
if (line === 'move') {
|
||||
const destinations = [...inventory.values()].filter(m => m.agent === current.agent && m.host !== current.host);
|
||||
destinations.forEach((m, i) => console.log(`${i + 1}. ${m.host} | ${JSON.stringify(m.body.selectedNext)} | locally provisioned key is NOT Start authority`));
|
||||
const target = destinations[Number(await ui.question('Destination number: ')) - 1];
|
||||
if (!target || Date.now()-Number(target.body.observedAt) > 6000) { console.log('Destination unknown/stale; repair local key/setup/auth and host connection. No Move sent.'); continue; }
|
||||
if (await ui.question(`Move agent ${current.agent} from ${current.host} to ${target.host}, fresh execution, no workspace/session/credentials transferred; source cannot resume after grant. Confirm [yes/no]: `) !== 'yes') continue;
|
||||
body = { target: target.host, targetRevision: target.revision, selection: target.body.selectedNext };
|
||||
}
|
||||
const request = message(line as 'save' | 'start' | 'stop' | 'move',current.host,current.agent,current.revision,body);
|
||||
try { client.submit(request); } catch (error) { console.log(error instanceof Error ? error.message : 'Operation not submitted'); continue; }
|
||||
console.log(`Durably pending ${line} (${request.id}); publication is NOT host acceptance.`);
|
||||
console.log(`Durably pending ${line}: ${operationLabel(request)}; publication is NOT host acceptance.`);
|
||||
}
|
||||
} finally { ui.close(); client.close(); console.log('UI closed; host lifetime is independent.'); }
|
||||
} else console.log(help);
|
||||
|
||||
31
beehive/src/handoff.ts
Normal file
31
beehive/src/handoff.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { digest, fields, object, text, type Message, parseMessage } from './protocol.ts';
|
||||
import { validateGenesis, type Genesis } from './assignment.ts';
|
||||
|
||||
/** Public exact launch selection; credentials and executable choices remain local. */
|
||||
export type Selection = { model: string; workspace: string; profile: string };
|
||||
/** A source-consumed successor. Outer management authentication assumes trusted hosts. */
|
||||
export type Grant = { root: string; predecessor: string; source: string; target: string; agent: string; operation: Message; prepared: string; selection: Selection; targetRevision: number; sourceRun: string | null };
|
||||
export type Assignment = { genesis: Genesis; assignedHost: string; chain?: Grant[] };
|
||||
export function hash(value: unknown): string { return digest(JSON.stringify(value)).toString('hex'); }
|
||||
export function selection(value: unknown): Selection {
|
||||
const s = object(value); fields(s, ['model','workspace','profile']);
|
||||
return { model: text(s.model), workspace: text(s.workspace), profile: text(s.profile) };
|
||||
}
|
||||
/** Validate every predecessor, routing and immutable operation binding, not timestamps. */
|
||||
export function validateAssignment(a: Assignment): void {
|
||||
const root = validateGenesis(a.genesis); let holder = root.initialHost, predecessor = hash(root);
|
||||
if (!Array.isArray(a.chain ?? []) || (a.chain?.length ?? 0) > 24) throw Error('Assignment chain limit');
|
||||
for (const g of a.chain ?? []) {
|
||||
fields(object(g), ['root','predecessor','source','target','agent','operation','prepared','selection','targetRevision','sourceRun']);
|
||||
const op = parseMessage(g.operation); fields(op.body, ['target','targetRevision','selection']);
|
||||
selection(g.selection); text(g.prepared);
|
||||
if (g.root !== hash(root) || g.predecessor !== predecessor || g.source !== holder || g.agent !== root.agent || g.target === holder || g.target !== op.body.target || op.host !== holder || op.agent !== root.agent || op.type !== 'move' || g.targetRevision !== op.body.targetRevision || hash(g.selection) !== hash(op.body.selection) || !Number.isSafeInteger(g.targetRevision) || g.targetRevision < 0 || (g.sourceRun !== null && typeof g.sourceRun !== 'string')) throw Error('Invalid consumed grant chain');
|
||||
text(g.target); holder = g.target; predecessor = hash(g);
|
||||
}
|
||||
if (a.assignedHost !== holder) throw Error('Saved assignment mismatch');
|
||||
}
|
||||
/** A recipient may only extend its durable knowledge, never accept historical authority. */
|
||||
export function extendsAssignment(current: Assignment, next: Assignment): boolean {
|
||||
validateAssignment(next);
|
||||
return hash(current.genesis) === hash(next.genesis) && (next.chain?.length ?? 0) > (current.chain?.length ?? 0) && (current.chain ?? []).every((g, i) => hash(g) === hash(next.chain![i]));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { accessSync, constants, existsSync, mkdirSync, rmdirSync, realpathSync, statSync, readFileSync } from 'node:fs';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { hash, selection, validateAssignment, extendsAssignment, type Assignment, type Grant } from './handoff.ts';
|
||||
import { installationSlots } from './slots.ts';
|
||||
import { validateGenesis, type Genesis } from './assignment.ts';
|
||||
import { connect, validateRelayURL } from './client.ts';
|
||||
@@ -13,7 +14,7 @@ import { prepareConversation, conversationSummary, type ConversationSetup } from
|
||||
|
||||
export type Setup = { host: string; ownerSecret: string; agentSecret: string; runner: string; args: string[]; workspace: string; mode: 'fixture' | 'buzz-agent-databricks-v2'; databricksHost?: string; serviceHome?: string; configDirectory?: string; conversation?: ConversationSetup };
|
||||
type Selection = { model: string; workspace: string; profile: string };
|
||||
type State = { assignment: { genesis: Genesis; assignedHost: string }; binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | { selection: Selection; executableHash: string; run: string; evidence?: Evidence }; operations: Record<string, { fingerprint: string; reply: Message }>; outbox: Message[] };
|
||||
type State = { assignment: Assignment; move?: { request: Message; prepare: Message }; preparations?: Record<string, { request: Message; token: string; reply: Message }>; incoming?: Record<string, Message>; binding: { host: string; owner: string; agent: string }; revision: number; phase: 'stopped' | 'transitioning' | 'running' | 'quarantined'; selected: Selection; actual: null | { selection: Selection; executableHash: string; run: string; evidence?: Evidence }; operations: Record<string, { fingerprint: string; reply: Message }>; outbox: Message[] };
|
||||
export function validateSetup(value: unknown): Setup {
|
||||
const s = object(value);
|
||||
for (const key of ['host','ownerSecret','agentSecret','runner','workspace']) text(s[key]);
|
||||
@@ -57,16 +58,20 @@ function slot(setup: Setup, path: string, currentSetup: () => Setup, publish: (m
|
||||
if (state.binding.host !== setup.host || state.binding.owner !== publicKey(setup.ownerSecret) || state.binding.agent !== agent) throw Error('Saved ownership/assignment mismatch');
|
||||
if (!state.assignment) throw Error('Legacy assignment requires explicit local migrate-assignment while stopped');
|
||||
const genesis = validateGenesis(state.assignment.genesis);
|
||||
if (genesis.owner !== state.binding.owner || genesis.agent !== agent || state.assignment.assignedHost !== genesis.initialHost) throw Error('Saved assignment mismatch');
|
||||
if (genesis.owner !== state.binding.owner || genesis.agent !== agent) throw Error('Saved assignment mismatch');
|
||||
validateAssignment(state.assignment);
|
||||
if (state.phase !== 'stopped') state.phase = 'quarantined';
|
||||
const save = () => writePrivate(path,state);
|
||||
let persistenceFailed = false;
|
||||
const save = () => { try { writePrivate(path,state); } catch (e) { persistenceFailed = true; throw e; } };
|
||||
// Preparation is tied to this process lifetime. Never launch from a recovered token.
|
||||
const livePreparations = new Set<string>();
|
||||
save();
|
||||
let owned: OwnedProcess | ConversationSession | undefined;
|
||||
let acp: AgentSession | ConversationSession | undefined;
|
||||
let catalog: Catalog | { state: 'not-probed' | 'failed'; authentication: 'unverified' } = { state: 'not-probed', authentication: 'unverified' };
|
||||
function inventory() {
|
||||
return message('inventory',setup.host,agent,state.revision, {
|
||||
assignedHost: state.assignment.assignedHost, genesis: state.assignment.genesis, executionAuthority: state.assignment.assignedHost === setup.host, phase: state.phase, selectedNext: state.selected, actualRun: state.actual,
|
||||
move: state.move ? 'destination preflight pending; source still assigned' : undefined, assignmentChain: state.assignment.chain ?? [], assignedHost: state.assignment.assignedHost, genesis: state.assignment.genesis, executionAuthority: state.assignment.assignedHost === setup.host, phase: state.phase, selectedNext: state.selected, actualRun: state.actual,
|
||||
...(setup.conversation ? { conversation: conversationSummary(setup.conversation) } : {}),
|
||||
catalog, setup: setup.mode, models: [setup.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5'],
|
||||
workspaces: [setup.workspace], profiles: ['default'], readiness: setup.conversation ? (state.actual?.evidence ? 'Conversation session model acknowledged and response completed; relay delivery unverified' : 'Waiting for an admitted conversation and local provider sign-in; no synthetic prompt') : setup.mode === 'fixture' ? 'fixture-only' : (state.phase === 'running' && state.actual?.evidence ? 'ACP model acknowledged + same-session response (not provider attestation or Buzz relay agent)' : 'unverified: Start runs an ACP greeting probe; sign in locally as host service user if required'), observedAt: Date.now(),
|
||||
@@ -88,7 +93,7 @@ function slot(setup: Setup, path: string, currentSetup: () => Setup, publish: (m
|
||||
function receive(m: Message) {
|
||||
// Cancellation is signalled outside the serialized mutation queue. Admission is
|
||||
// still exact-authority/revision and never bypasses durable receipt processing.
|
||||
const operation = m.host === setup.host && ['save','start','stop'].includes(m.type);
|
||||
const operation = m.host === setup.host && ['save','start','stop','move'].includes(m.type);
|
||||
const first = operation && !queuedOperations.has(m.id);
|
||||
if (operation) queuedOperations.add(m.id);
|
||||
// Reserve receive order too: a conflicting queued ID is not a Stop authority.
|
||||
@@ -98,29 +103,144 @@ function slot(setup: Setup, path: string, currentSetup: () => Setup, publish: (m
|
||||
// and the pre-commit recheck consume the retraction inside handle().
|
||||
if (state.phase === 'transitioning' && state.actual) acp?.cancel();
|
||||
}
|
||||
if (first && state.move && m.type === 'save' && m.revision === state.revision && state.assignment.assignedHost === setup.host && !Object.hasOwn(state.operations, m.id)) {
|
||||
try { const v = selection(m.body); if (v.model === state.selected.model && v.workspace === setup.workspace && v.profile === 'default') retracted.set(m.id, m.revision); } catch { /* Invalid Save cannot cancel. */ }
|
||||
}
|
||||
queue = queue.then(() => handle(m)).catch(() => { state.phase = 'quarantined'; save(); });
|
||||
}
|
||||
function replay() {
|
||||
for (const m of state.outbox) publish(m);
|
||||
if (state.move) publish(state.move.prepare);
|
||||
publish(inventory());
|
||||
}
|
||||
async function stopOwned() {
|
||||
if (!owned) throw Error('Unknown ownership; quarantined');
|
||||
await owned.stop(); owned = undefined; acp = undefined;
|
||||
}
|
||||
function prepareLocal(selected: Selection) {
|
||||
selection(selected);
|
||||
if (selected.model !== (setup.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5') || selected.workspace !== setup.workspace || selected.profile !== 'default') throw Error('Unsupported destination selection');
|
||||
try {
|
||||
if (hash(currentSetup()) !== hash(setup)) throw Error('changed');
|
||||
} catch { throw Error('Local agent key/setup missing or changed; repair locally without resetting assignment'); }
|
||||
accessSync(setup.runner, constants.X_OK);
|
||||
if (realpathSync(setup.workspace) !== setup.workspace || !statSync(setup.workspace).isDirectory()) throw Error('Workspace changed');
|
||||
const launch: AgentLaunch | undefined = setup.mode === 'fixture' ? undefined : { executable: setup.runner, args: setup.args, workspace: selected.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: text(setup.databricksHost), model: selected.model };
|
||||
const prepared = launch ? prepareAgent(launch) : undefined;
|
||||
const conversation = setup.conversation && launch ? prepareConversation(setup.conversation, launch, setup.agentSecret, state.binding.owner) : undefined;
|
||||
// Local hashes only; no setup or secret values leave the host. Include existing
|
||||
// script inputs and directory identity so replacement invalidates preparation.
|
||||
const scripts = setup.args.filter(a => isAbsolute(a) && existsSync(a)).map(a => createHash('sha256').update(readFileSync(a)).digest('hex'));
|
||||
const workspace = statSync(setup.workspace);
|
||||
return { launch, token: hash({ setup, selected, prepared, conversation, scripts, executable: createHash('sha256').update(readFileSync(setup.runner)).digest('hex'), workspace: [workspace.dev, workspace.ino] }) };
|
||||
}
|
||||
function finishMove(result: string) {
|
||||
const pending = state.move;
|
||||
if (!pending) return;
|
||||
const request = pending.request, fingerprint = hash(request);
|
||||
const reply = message('receipt', setup.host, agent, state.revision, { operation: request.id, fingerprint, result, assignedHost: state.assignment.assignedHost, transfer: result === 'accepted' ? 'source consumed; grant queued; destination launch observed separately' : 'no grant' });
|
||||
state.operations[request.id] = { fingerprint, reply }; state.outbox.push(reply);
|
||||
delete state.move; save(); publish(reply); publish(inventory());
|
||||
}
|
||||
async function exchange(m: Message) {
|
||||
try {
|
||||
if (m.type === 'prepare') {
|
||||
fields(m.body, ['assignment','operation']);
|
||||
if (setup.mode !== 'fixture' || setup.conversation) throw Error('Move exact-model preflight is not implemented for conversation/provider harnesses');
|
||||
const assignment = m.body.assignment as Assignment; validateAssignment(assignment);
|
||||
const op = m.body.operation as Message;
|
||||
// Validate the proposed grant shape through the same chain validator.
|
||||
const g: Grant = { root: hash(assignment.genesis), predecessor: hash(assignment.chain?.at(-1) ?? assignment.genesis), source: op.host, target: setup.host, agent, operation: op, prepared: 'pending', selection: selection(op.body.selection), targetRevision: m.revision, sourceRun: null };
|
||||
validateAssignment({ ...assignment, assignedHost: setup.host, chain: [...(assignment.chain ?? []), g] });
|
||||
if (hash(assignment.genesis) !== hash(genesis) || !((state.assignment.chain ?? []).every((g, i) => hash(g) === hash(assignment.chain?.[i]))) || state.phase !== 'stopped' || state.assignment.assignedHost === setup.host || m.revision !== state.revision) throw Error('Destination authority/revision conflict');
|
||||
const prior = state.preparations?.[op.id];
|
||||
if (prior) {
|
||||
if (hash(prior.request) !== hash(m)) throw Error('Preparation ID conflict');
|
||||
if (livePreparations.has(op.id)) { publish(prior.reply); return; }
|
||||
}
|
||||
if (!prior && Object.keys(state.preparations ?? {}).length >= 1000) throw Error('Preparation journal full; local reconciliation needed');
|
||||
const token = prepareLocal(g.selection).token;
|
||||
const reply = message('prepared', op.host, agent, op.revision, { prepare: m, token });
|
||||
state.preparations ??= {}; state.preparations[op.id] = { request: m, token, reply }; livePreparations.add(op.id); save(); publish(reply);
|
||||
} else if (m.type === 'prepared') {
|
||||
const pending = state.move;
|
||||
if (!pending || hash(m.body.prepare) !== hash(pending.prepare) || m.revision !== pending.request.revision || state.revision !== m.revision || state.assignment.assignedHost !== setup.host) return;
|
||||
if (m.body.error) { finishMove(String(m.body.error)); return; }
|
||||
fields(m.body, ['prepare','token']); const token = text(m.body.token);
|
||||
if (state.phase !== 'stopped') {
|
||||
if (state.phase !== 'running' || !owned) throw Error('Source ownership unknown; no grant');
|
||||
state.phase = 'transitioning'; save(); await stopOwned();
|
||||
}
|
||||
// Stop/Save admitted during the await retract exactly this source revision.
|
||||
state.phase = 'stopped'; const sourceRun = state.actual?.run ?? null; state.actual = null;
|
||||
if (retracting(m.revision) || closing) { finishMove('Move cancelled after source Stop; no grant'); return; }
|
||||
const op = pending.request;
|
||||
const grant: Grant = { root: hash(genesis), predecessor: hash(state.assignment.chain?.at(-1) ?? genesis), source: setup.host, target: text(op.body.target), agent, operation: op, prepared: token, selection: selection(op.body.selection), targetRevision: Number(op.body.targetRevision), sourceRun };
|
||||
const assignment: Assignment = { genesis, assignedHost: grant.target, chain: [...(state.assignment.chain ?? []), grant] }; validateAssignment(assignment);
|
||||
const delivery = message('grant', grant.target, agent, grant.targetRevision, { assignment });
|
||||
// Irreversible authority consumption + exact grant outbox precede publication.
|
||||
state.assignment = assignment; state.revision++; state.outbox.push(delivery);
|
||||
finishMove('accepted'); publish(delivery);
|
||||
} else {
|
||||
fields(m.body, ['assignment']); const next = m.body.assignment as Assignment;
|
||||
validateAssignment(next); const g = next.chain?.at(-1);
|
||||
if (!g || g.target !== setup.host || g.agent !== agent || m.revision !== g.targetRevision) return;
|
||||
const prior = state.incoming?.[hash(g)];
|
||||
if (prior) { publish(prior); return; }
|
||||
if (!extendsAssignment(state.assignment, next) || state.assignment.assignedHost === setup.host || state.phase !== 'stopped') return;
|
||||
const prep = state.preparations?.[g.operation.id];
|
||||
if (!prep || hash(prep.request.body.operation) !== hash(g.operation) || prep.token !== g.prepared || state.revision !== g.targetRevision) return;
|
||||
if (hash(prep.request.body.assignment) !== hash({ genesis: next.genesis, assignedHost: g.source, ...(next.chain!.length > 1 ? { chain: next.chain!.slice(0,-1) } : {}) })) {
|
||||
// Genesis-only journals may explicitly retain an empty chain.
|
||||
const before = prep.request.body.assignment as Assignment;
|
||||
if (hash(before.genesis) !== hash(next.genesis) || before.assignedHost !== g.source || hash(before.chain ?? []) !== hash(next.chain!.slice(0,-1))) return;
|
||||
}
|
||||
// Accept before validating launch again: a stale/missing prerequisite can
|
||||
// fail launch but must never return authority to the consumed source.
|
||||
state.assignment = next; state.selected = g.selection;
|
||||
const receipt = message('receipt', setup.host, agent, state.revision, { operation: m.id, fingerprint: hash(m), result: 'destination assigned; launch pending' });
|
||||
state.incoming ??= {}; state.incoming[hash(g)] = receipt; state.outbox.push(receipt); save(); publish(receipt);
|
||||
let outcome: unknown;
|
||||
try {
|
||||
if (!livePreparations.has(g.operation.id) || prepareLocal(g.selection).token !== g.prepared) throw Error('Destination preparation invalidated; assigned here, stopped; repair local setup/key/auth then explicit Start');
|
||||
const start = message('start', setup.host, agent, state.revision); start.id = g.operation.id;
|
||||
await handle(start);
|
||||
outcome = state.operations[start.id]?.reply.body.result;
|
||||
} catch (e) { outcome = e instanceof Error ? e.message : 'Destination launch failed'; }
|
||||
livePreparations.delete(g.operation.id);
|
||||
const completed = message('receipt', setup.host, agent, state.revision, { operation: m.id, fingerprint: hash(m), result: outcome, assignedHost: setup.host, transfer: 'source consumed; destination assigned' });
|
||||
state.incoming[hash(g)] = completed; state.outbox.push(completed); save(); publish(completed); publish(inventory());
|
||||
}
|
||||
} catch (e) {
|
||||
if (persistenceFailed) throw e;
|
||||
if (m.type === 'prepare') {
|
||||
const op = m.body.operation as Message;
|
||||
if (op?.host) publish(message('prepared', op.host, agent, op.revision, { prepare: m, error: 'Destination preflight failed: locally provision matching key, allowed workspace and harness/auth setup; no source Stop' }));
|
||||
} else if (m.type === 'prepared') {
|
||||
if (state.phase === 'transitioning') state.phase = 'quarantined';
|
||||
finishMove(e instanceof Error ? e.message : 'Move failed; reconcile ownership');
|
||||
} // Invalid grants are inert; they cannot change local authority.
|
||||
}
|
||||
}
|
||||
async function handle(m: Message) {
|
||||
if (m.host === setup.host && ['save','start','stop'].includes(m.type)) queuedOperations.delete(m.id);
|
||||
if (closing || m.host !== setup.host || !['inspect','save','start','stop'].includes(m.type)) return;
|
||||
if (m.host === setup.host && ['save','start','stop','move'].includes(m.type)) queuedOperations.delete(m.id);
|
||||
if (closing || persistenceFailed || m.host !== setup.host || !['inspect','save','start','stop','move','prepare','prepared','grant'].includes(m.type)) return;
|
||||
if (['prepare','prepared','grant'].includes(m.type)) { await exchange(m); return; }
|
||||
// Serialized Stop processing durably resolves that Stop's own retraction.
|
||||
if (m.type === 'stop') retracted.delete(m.id);
|
||||
if (m.type === 'inspect') { for (const receipt of state.outbox) publish(receipt); publish(inventory()); return; }
|
||||
if (m.type === 'stop' || m.type === 'save') retracted.delete(m.id);
|
||||
if (m.type === 'inspect') { if (state.move) publish(state.move.prepare); for (const receipt of state.outbox) publish(receipt); publish(inventory()); return; }
|
||||
const fingerprint = digest(JSON.stringify(m)).toString('hex');
|
||||
const previous = Object.hasOwn(state.operations,m.id) ? state.operations[m.id] : undefined;
|
||||
if (previous) {
|
||||
// Move awaits a relay reply outside this queue: reservation is not terminal.
|
||||
if (state.move?.request.id === m.id && previous.fingerprint === fingerprint) {
|
||||
publish(state.move.prepare); publish(inventory()); return;
|
||||
}
|
||||
publish(previous.fingerprint === fingerprint ? previous.reply : message('receipt',setup.host,m.agent,state.revision,{ operation: m.id, fingerprint, result: 'operation-id-conflict' })); return;
|
||||
}
|
||||
let result = 'accepted';
|
||||
if (m.agent !== agent || state.assignment.assignedHost !== setup.host) result = 'not-authority';
|
||||
else if (state.move && ['start','move'].includes(m.type)) result = 'Move reservation busy';
|
||||
else if (m.revision !== state.revision) result = 'revision-conflict';
|
||||
else if ((state.phase === 'quarantined' || state.phase === 'transitioning') && !(m.type === 'stop' && (owned instanceof ConversationSession ? owned.connected : owned?.child.connected))) result = 'quarantined';
|
||||
else {
|
||||
@@ -128,35 +248,32 @@ function slot(setup: Setup, path: string, currentSetup: () => Setup, publish: (m
|
||||
const pending = message('receipt',setup.host,agent,state.revision,{ operation: m.id, fingerprint, result: 'interrupted-reconcile-locally' });
|
||||
state.operations[m.id] = { fingerprint, reply: pending }; save();
|
||||
try {
|
||||
if (m.type === 'save') {
|
||||
if (state.move && m.type === 'stop') { fields(m.body, []); finishMove('Move cancelled before grant by stop'); }
|
||||
if (m.type === 'move') {
|
||||
fields(m.body, ['target','targetRevision','selection']);
|
||||
if (setup.mode !== 'fixture' || setup.conversation) throw Error('Move is fixture-only until exact-model preflight and containment acceptance are complete');
|
||||
const target = text(m.body.target); selection(m.body.selection);
|
||||
if (target === setup.host || !Number.isSafeInteger(m.body.targetRevision) || Number(m.body.targetRevision) < 0 || (state.assignment.chain?.length ?? 0) >= 24) throw Error('Invalid destination or chain limit');
|
||||
const prepare = message('prepare', target, agent, Number(m.body.targetRevision), { assignment: state.assignment, operation: m });
|
||||
state.move = { request: m, prepare }; save(); publish(prepare); publish(inventory()); return;
|
||||
} else if (m.type === 'save') {
|
||||
fields(m.body,['model','workspace','profile']);
|
||||
const selected = { model: text(m.body.model), workspace: text(m.body.workspace), profile: text(m.body.profile) };
|
||||
if (selected.model !== (setup.mode === 'fixture' ? 'fixture-model' : 'databricks-claude-haiku-4-5') || selected.workspace !== setup.workspace || selected.profile !== 'default') throw Error('Unsupported selection');
|
||||
if (state.move) finishMove('Move cancelled before grant by save');
|
||||
state.selected = selected; result = 'saved; running configuration unchanged';
|
||||
} else if (m.type === 'start') {
|
||||
fields(m.body,[]);
|
||||
if (state.phase !== 'stopped') throw Error('Already running');
|
||||
// A retracted admission never begins effects, including any spawn.
|
||||
if (retracting(m.revision)) throw Error('Start cancelled by concurrent Stop');
|
||||
// Lifecycle never restores deleted keys from the in-memory setup snapshot.
|
||||
try {
|
||||
const current = currentSetup();
|
||||
if (current.agentSecret !== setup.agentSecret || current.ownerSecret !== setup.ownerSecret || current.host !== setup.host) throw Error('changed');
|
||||
} catch { throw Error('Local agent key/setup missing or changed; repair locally without resetting assignment'); }
|
||||
accessSync(setup.runner,constants.X_OK);
|
||||
if (realpathSync(setup.workspace) !== setup.workspace || !statSync(setup.workspace).isDirectory()) throw Error('Workspace changed');
|
||||
const launch: AgentLaunch | undefined = setup.mode === 'fixture' ? undefined : { executable: setup.runner, args: setup.args, workspace: state.selected.workspace, home: text(setup.serviceHome), configDirectory: text(setup.configDirectory), databricksHost: text(setup.databricksHost), model: state.selected.model };
|
||||
if (launch) prepareAgent(launch); // Prerequisite failure preserves stopped state.
|
||||
if (setup.conversation) {
|
||||
if (!launch) throw Error('Conversation requires a Buzz Agent harness setup');
|
||||
prepareConversation(setup.conversation, launch, setup.agentSecret, state.binding.owner);
|
||||
}
|
||||
const { launch } = prepareLocal(state.selected);
|
||||
state.actual = { selection: structuredClone(state.selected), executableHash: createHash('sha256').update(readFileSync(setup.runner)).digest('hex'), run: m.id };
|
||||
state.phase = 'transitioning'; save();
|
||||
if (setup.mode === 'fixture') {
|
||||
owned = spawnOwned(setup.runner, setup.args, state.actual.selection.workspace, { PATH: '/usr/bin:/bin', BEEHIVE_FIXTURE: '1' });
|
||||
owned.child.stdout.resume(); owned.child.stderr.resume();
|
||||
await owned.ready;
|
||||
try { await owned.ready; } catch (e) { await stopOwned(); state.phase = 'stopped'; state.actual = null; throw e; }
|
||||
} else if (setup.conversation) {
|
||||
const session = new ConversationSession(setup.conversation, launch!, setup.agentSecret, state.binding.owner);
|
||||
acp = session; owned = session;
|
||||
@@ -197,6 +314,7 @@ function slot(setup: Setup, path: string, currentSetup: () => Setup, publish: (m
|
||||
}
|
||||
state.revision++;
|
||||
} catch (error) {
|
||||
if (persistenceFailed) throw error;
|
||||
if (state.phase === 'transitioning') state.phase = 'quarantined';
|
||||
result = error instanceof Error ? error.message : 'Operation failed';
|
||||
}
|
||||
@@ -248,7 +366,7 @@ export async function host(directory: string, url: string) {
|
||||
const selected = slots.get(m.agent);
|
||||
if (selected) selected.receive(m);
|
||||
else if (m.type === 'inspect') { for (const s of slots.values()) s.replay(); }
|
||||
else if (['save','start','stop'].includes(m.type)) publish(message('receipt',setup.host,m.agent,0,{ operation: m.id, fingerprint: digest(JSON.stringify(m)).toString('hex'), result: 'not-authority' }));
|
||||
else if (['save','start','stop','move'].includes(m.type)) publish(message('receipt',setup.host,m.agent,0,{ operation: m.id, fingerprint: digest(JSON.stringify(m)).toString('hex'), result: 'not-authority' }));
|
||||
}, () => { if (initialized) for (const s of slots.values()) s.replay(); });
|
||||
await client.ready;
|
||||
} catch (e) { client?.close(); rmdirSync(lock); throw e; }
|
||||
|
||||
@@ -33,7 +33,7 @@ export function managementClient(root: string, url: string, secret: string, rece
|
||||
const value = object(readPrivate(path)); fields(value, ['scope', 'envelope']);
|
||||
if (value.scope !== scope) throw Error('Wrong journal scope');
|
||||
const request = open(value.envelope, secret);
|
||||
if (!['save','start','stop'].includes(request.type) || name !== `${request.id}.intent`) throw Error('Invalid intent');
|
||||
if (!['save','start','stop','move'].includes(request.type) || name !== `${request.id}.intent`) throw Error('Invalid intent');
|
||||
const intent: Intent = { envelope: value.envelope as Envelope, request, published: false };
|
||||
const receiptPath = join(dir, `${request.id}.receipt`);
|
||||
if (existsSync(receiptPath)) {
|
||||
@@ -97,7 +97,7 @@ export function managementClient(root: string, url: string, secret: string, rece
|
||||
get socket() { return transport.socket; },
|
||||
submit(request: Message) {
|
||||
if (closed) throw Error('UI closed');
|
||||
if (!['save','start','stop'].includes(request.type)) throw Error('Invalid operation');
|
||||
if (!['save','start','stop','move'].includes(request.type)) throw Error('Invalid operation');
|
||||
if (intents.size >= 1000) throw Error('Management journal full; no operation submitted');
|
||||
if (intents.has(request.id) || existsSync(join(dir, `${request.id}.intent`))) throw Error('Operation ID already prepared');
|
||||
const envelope = seal(request, secret);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash, createCipheriv, createDecipheriv, randomBytes, randomUUID }
|
||||
import { schnorr } from '@noble/curves/secp256k1';
|
||||
|
||||
/** Experimental private wire protocol; deliberately not a Nostr kind allocation. */
|
||||
export type Message = { v: 1; id: string; host: string; agent: string; type: 'inspect' | 'save' | 'start' | 'stop' | 'inventory' | 'receipt'; revision: number; body: Record<string, unknown> };
|
||||
export type Message = { v: 1; id: string; host: string; agent: string; type: 'inspect' | 'save' | 'start' | 'stop' | 'move' | 'prepare' | 'prepared' | 'grant' | 'inventory' | 'receipt'; revision: number; body: Record<string, unknown> };
|
||||
export type Envelope = { v: 1; owner: string; nonce: string; ciphertext: string; tag: string; signature: string };
|
||||
/** Reject unknown envelope/message fields, oversized values and ambiguous revisions. */
|
||||
export function object(value: unknown): Record<string, unknown> {
|
||||
@@ -19,7 +19,7 @@ export function fields(value: Record<string, unknown>, names: string[]) {
|
||||
export function parseMessage(value: unknown): Message {
|
||||
const m = object(value);
|
||||
fields(m, ['v','id','host','agent','type','revision','body']);
|
||||
if (m.v !== 1 || !['inspect','save','start','stop','inventory','receipt'].includes(String(m.type)) || !Number.isSafeInteger(m.revision) || Number(m.revision) < 0) throw Error('Invalid message');
|
||||
if (m.v !== 1 || !['inspect','save','start','stop','move','prepare','prepared','grant','inventory','receipt'].includes(String(m.type)) || !Number.isSafeInteger(m.revision) || Number(m.revision) < 0) throw Error('Invalid message');
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(text(m.id))) throw Error('Invalid operation ID');
|
||||
text(m.host); text(m.agent); object(m.body);
|
||||
return m as Message;
|
||||
|
||||
195
beehive/test/move.test.ts
Normal file
195
beehive/test/move.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { mkdtempSync, realpathSync, rmSync, readFileSync, unlinkSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { createGenesis } from '../src/assignment.ts';
|
||||
import { provision, host, type Setup } from '../src/host.ts';
|
||||
import { migrateSlots, addSlot } from '../src/slots.ts';
|
||||
import { newKey, publicKey, message, open, type Message } from '../src/protocol.ts';
|
||||
import { writePrivate } from '../src/storage.ts';
|
||||
import { relay } from '../src/relay.ts';
|
||||
import { connect } from '../src/client.ts';
|
||||
|
||||
test('two real hosts: source-consumed Move, sibling isolation, preflight refusal, historical replay', { timeout: 30000 }, async () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-move-')));
|
||||
const ownerSecret = newKey(), x = newKey(), y = newKey(), agent = publicKey(x);
|
||||
const genesis = createGenesis(publicKey(ownerSecret), agent, 'source');
|
||||
const setup = (host: string): Setup => ({ host, ownerSecret, agentSecret: x, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' });
|
||||
provision(join(dir, 'source'), setup('source'), genesis); provision(join(dir, 'target'), setup('target'), genesis);
|
||||
migrateSlots(join(dir, 'source')); addSlot(join(dir, 'source'), y, createGenesis(publicKey(ownerSecret), publicKey(y), 'source'));
|
||||
const server = await relay(0, publicKey(ownerSecret), join(dir, 'relay.json'));
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const url = `ws://127.0.0.1:${address.port}`, seen: Message[] = [], children: ChildProcess[] = [];
|
||||
const ui = connect(url, ownerSecret, m => seen.push(m)); await ui.ready;
|
||||
async function wait(predicate: (m: Message) => boolean) {
|
||||
for (let i = 0; i < 500; i++) { const m = seen.find(predicate); if (m) return m; await delay(10); }
|
||||
throw Error('Missing Move evidence');
|
||||
}
|
||||
const journal = (name: string) => JSON.parse(readFileSync(join(dir, name, 'journal.json'), 'utf8'));
|
||||
async function request(type: Message['type'], host: string, revision: number, body = {}, key = agent) {
|
||||
const m = message(type, host, key, revision, body); ui.send(m);
|
||||
return wait(r => r.type === 'receipt' && r.body.operation === m.id && r.host === host);
|
||||
}
|
||||
try {
|
||||
for (const name of ['source','target']) {
|
||||
const child = spawn(process.execPath, ['src/cli.ts','host',join(dir,name),url], { stdio: ['ignore','pipe','pipe'] });
|
||||
children.push(child); child.stdout?.resume(); child.stderr?.resume(); await wait(m => m.type === 'inventory' && m.host === name);
|
||||
}
|
||||
assert.equal((await request('start','source',0)).body.result,'accepted');
|
||||
assert.equal((await request('start','source',0,{},publicKey(y))).body.result,'accepted');
|
||||
const siblingPath = join(dir,'source','agents',publicKey(y),'journal.json');
|
||||
const sibling = readFileSync(siblingPath,'utf8');
|
||||
assert.equal((await request('start','target',0)).body.result,'not-authority');
|
||||
const selected = { model: 'fixture-model', workspace: dir, profile: 'default' };
|
||||
const failed = await request('move','source',1,{ target: 'target', targetRevision: 9, selection: selected });
|
||||
assert.match(String(failed.body.result),/preflight failed/);
|
||||
assert.equal(journal('source').phase,'running'); assert.equal(journal('source').revision,1);
|
||||
writePrivate(join(dir,'identity.json'),{ secret: ownerSecret });
|
||||
const terminal = spawn(process.execPath,['src/cli.ts','tui',join(dir,'identity.json'),url],{ stdio:['pipe','pipe','pipe'] }); children.push(terminal);
|
||||
let output = '', cursor = 0, index = 0;
|
||||
const steps = [
|
||||
{ prompt:'beehive> ', value:() => 'hosts' },
|
||||
{ prompt:'beehive> ', value:() => { const row = output.match(new RegExp(`(\\d+)\\. source \\| agent ${agent}`)); assert.ok(row); return `select ${row[1]}`; } },
|
||||
{ prompt:'beehive> ', value:() => 'move' },
|
||||
{ prompt:'Destination number: ', value:() => '1' },
|
||||
{ prompt:'Confirm [yes/no]: ', value:() => 'yes' },
|
||||
{ prompt:'beehive> ', value:() => 'quit' },
|
||||
];
|
||||
terminal.stdout.on('data',chunk => {
|
||||
output += chunk.toString(); const step = steps[index];
|
||||
if (!step) return;
|
||||
const position = output.indexOf(step.prompt, cursor); if (position < 0) return;
|
||||
index++; cursor = position + step.prompt.length;
|
||||
setImmediate(() => terminal.stdin.write(`${step.value()}\n`));
|
||||
}); terminal.stderr.resume();
|
||||
const [code] = await once(terminal,'exit'); assert.equal(code,0); assert.match(output,/Durably pending move/);
|
||||
const move = await wait(m => m.type === 'move' && m.host === 'source' && m.body.targetRevision === 0);
|
||||
|
||||
const result = await wait(m => m.type === 'receipt' && m.body.operation === move.id && m.host === 'source'); assert.equal(result.body.result,'accepted');
|
||||
await wait(m => m.type === 'inventory' && m.host === 'target' && m.body.phase === 'running');
|
||||
assert.equal(journal('source').assignment.assignedHost,'target'); assert.equal(journal('source').phase,'stopped'); assert.equal(journal('source').actual,null);
|
||||
assert.equal(journal('target').assignment.assignedHost,'target'); assert.equal(journal('target').actual.run,move.id);
|
||||
assert.equal(readFileSync(siblingPath,'utf8'),sibling);
|
||||
const grant = await wait(m => m.type === 'grant'); const before = readFileSync(join(dir,'target','journal.json'),'utf8');
|
||||
ui.send(move); ui.send(grant); await delay(100); assert.equal(readFileSync(join(dir,'target','journal.json'),'utf8'),before);
|
||||
assert.equal((await request('start','source',2)).body.result,'not-authority');
|
||||
// Move back is a NEW successor; historical forward grant cannot seize it.
|
||||
assert.equal((await request('move','target',1,{ target: 'source', targetRevision: 2, selection: selected })).body.result,'accepted');
|
||||
await wait(m => m.type === 'inventory' && m.host === 'source' && m.revision === 3 && m.body.phase === 'running');
|
||||
ui.send(grant); await delay(100);
|
||||
assert.equal(journal('target').assignment.assignedHost,'source'); assert.equal(journal('target').phase,'stopped');
|
||||
assert.equal(journal('source').assignment.chain.length,2);
|
||||
assert.equal((await request('stop','source',3)).body.result,'accepted');
|
||||
assert.equal((await request('stop','source',1,{},publicKey(y))).body.result,'accepted');
|
||||
} finally {
|
||||
for (const child of children) { if (child.exitCode === null && child.signalCode === null) { const exit = once(child,'exit'); child.kill('SIGTERM'); await exit; } }
|
||||
ui.close(); for (const c of server.clients) c.terminate(); await new Promise<void>(resolve => server.close(() => resolve())); rmSync(dir,{ recursive:true,force:true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
for (const failure of ['missing-key', 'changed-prepared-input'] as const) test(`dropped grant/receipt recovery; post-grant ${failure} leaves target assigned stopped`, { timeout: 20000 }, async () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-move-loss-')));
|
||||
const ownerSecret = newKey(), agentSecret = newKey(), agent = publicKey(agentSecret);
|
||||
const genesis = createGenesis(publicKey(ownerSecret), agent, 'source');
|
||||
for (const name of ['source','target']) provision(join(dir,name), { host: name, ownerSecret, agentSecret, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' },genesis);
|
||||
const server = await relay(0, publicKey(ownerSecret), join(dir,'relay.json'));
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string'); const url = `ws://127.0.0.1:${address.port}`;
|
||||
let source = await host(join(dir,'source'),url); const target = await host(join(dir,'target'),url);
|
||||
const sourceSocket = [...server.clients][0]!;
|
||||
let lostOperation = '', loseReceipt = true;
|
||||
const handlers = sourceSocket.listeners('message'); sourceSocket.removeAllListeners('message');
|
||||
sourceSocket.on('message', (raw, binary) => {
|
||||
const m = open(JSON.parse(String(raw)),ownerSecret);
|
||||
if (loseReceipt && m.type === 'receipt' && m.body.operation === lostOperation) return;
|
||||
for (const handler of handlers) Reflect.apply(handler, sourceSocket, [raw,binary]);
|
||||
});
|
||||
const targetSocket = [...server.clients][1]!; const send = targetSocket.send.bind(targetSocket); let drop = true;
|
||||
targetSocket.send = ((data: Parameters<typeof send>[0], ...args: unknown[]) => {
|
||||
const m = open(JSON.parse(String(data)),ownerSecret);
|
||||
if (drop && m.type === 'grant') return;
|
||||
return (send as Function)(data,...args);
|
||||
}) as typeof targetSocket.send;
|
||||
const seen: Message[] = []; const ui = connect(url,ownerSecret,m => seen.push(m)); await ui.ready;
|
||||
const journal = (name: string) => JSON.parse(readFileSync(join(dir,name,'journal.json'),'utf8'));
|
||||
async function until(predicate: () => boolean) { for (let i=0;i<500;i++) { if (predicate()) return; await delay(10); } throw Error('Missing failure/recovery evidence'); }
|
||||
try {
|
||||
const start = message('start','source',agent,0); ui.send(start); await until(() => journal('source').phase === 'running');
|
||||
const move = message('move','source',agent,1,{ target:'target',targetRevision:0,selection:{ model:'fixture-model',workspace:dir,profile:'default' } }); lostOperation = move.id; ui.send(move);
|
||||
await until(() => journal('source').assignment.assignedHost === 'target');
|
||||
assert.equal(journal('source').phase,'stopped'); assert.equal(journal('target').assignment.assignedHost,'source');
|
||||
assert.equal(seen.some(m => m.type === 'receipt' && m.body.operation === move.id),false, 'source receipt lost before relay storage');
|
||||
const targetSetup = join(dir,'target','setup.json');
|
||||
if (failure === 'missing-key') unlinkSync(targetSetup);
|
||||
else writePrivate(targetSetup, { ...JSON.parse(readFileSync(targetSetup,'utf8')), args: [resolve('test/runner.ts'), 'changed-after-preparation'] });
|
||||
drop = false; loseReceipt = false;
|
||||
ui.send(message('inspect','source',agent));
|
||||
await until(() => seen.some(m => m.type === 'receipt' && m.body.operation === move.id && m.body.result === 'accepted'));
|
||||
await until(() => journal('target').assignment.assignedHost === 'target');
|
||||
await until(() => seen.some(m => m.host === 'target' && m.type === 'receipt' && String(m.body.result).includes('Local agent key/setup missing')));
|
||||
assert.equal(journal('target').phase,'stopped'); assert.equal(journal('target').actual,null);
|
||||
const denied = message('start','source',agent,2); ui.send(denied);
|
||||
await until(() => seen.some(m => m.body.operation === denied.id && m.body.result === 'not-authority'));
|
||||
const state = journal('target');
|
||||
for (const grant of seen.filter(m => m.type === 'grant')) ui.send(grant);
|
||||
await delay(100); assert.deepEqual(journal('target'),state);
|
||||
await source.close(); source = await host(join(dir,'source'),url);
|
||||
const restarted = message('start','source',agent,2); ui.send(restarted);
|
||||
await until(() => seen.some(m => m.body.operation === restarted.id && m.body.result === 'not-authority'));
|
||||
assert.equal(journal('source').assignment.assignedHost,'target', 'service restart cannot resurrect consumed source');
|
||||
} finally {
|
||||
await source.close(); await target.close(); ui.close(); for (const c of server.clients) c.terminate();
|
||||
await new Promise<void>(resolve => server.close(() => resolve())); rmSync(dir,{ recursive:true,force:true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Stop and Save retract exact source reservation while destination preflight reply is delayed', { timeout: 20000 }, async () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(),'beehive-move-cancel-')));
|
||||
const ownerSecret = newKey(), agentSecret = newKey(), agent = publicKey(agentSecret);
|
||||
const genesis = createGenesis(publicKey(ownerSecret),agent,'source');
|
||||
for (const name of ['source','target']) provision(join(dir,name), { host:name, ownerSecret,agentSecret,runner:process.execPath,args:[resolve('test/runner.ts')],workspace:dir,mode:'fixture' },genesis);
|
||||
const server = await relay(0,publicKey(ownerSecret),join(dir,'relay.json')); const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const url = `ws://127.0.0.1:${address.port}`;
|
||||
const source = await host(join(dir,'source'),url), target = await host(join(dir,'target'),url);
|
||||
const socket = [...server.clients][0]!, send = socket.send.bind(socket); const held: Parameters<typeof send>[0][] = [];
|
||||
socket.send = ((data: Parameters<typeof send>[0], ...args: unknown[]) => {
|
||||
if (open(JSON.parse(String(data)),ownerSecret).type === 'prepared') { held.push(data); return; }
|
||||
return (send as Function)(data,...args);
|
||||
}) as typeof socket.send;
|
||||
const seen: Message[] = []; const ui = connect(url,ownerSecret,m => seen.push(m)); await ui.ready;
|
||||
const journal = () => JSON.parse(readFileSync(join(dir,'source','journal.json'),'utf8'));
|
||||
async function until(predicate: () => boolean) { for(let i=0;i<500;i++) { if(predicate()) return; await delay(10); } throw Error('Missing cancellation evidence'); }
|
||||
const selected = { model:'fixture-model',workspace:dir,profile:'default' };
|
||||
try {
|
||||
ui.send(message('start','source',agent)); await until(() => journal().phase === 'running');
|
||||
const actual = journal().actual;
|
||||
for (const [revision,action] of [[1,'save'],[2,'stop']] as const) {
|
||||
const move = message('move','source',agent,revision,{ target:'target',targetRevision:0,selection:selected }); ui.send(move);
|
||||
await until(() => held.length > 0);
|
||||
ui.send(move); await until(() => held.length > 1);
|
||||
assert.equal(seen.some(m => m.type === 'receipt' && m.body.operation === move.id),false, 'exact pending retry must not publish a terminal interrupted receipt');
|
||||
const invalid = message('stop','source',agent,revision+20); ui.send(invalid);
|
||||
await until(() => seen.some(m => m.body.operation === invalid.id && m.body.result === 'revision-conflict'));
|
||||
assert.equal(journal().move.request.id,move.id);
|
||||
for (const invalid of [message('stop','source',agent,revision,{ unexpected:true }), message('save','source',agent,revision,{ ...selected, model:'unsupported' }), message('start','source',agent,revision)]) {
|
||||
ui.send(invalid); await until(() => seen.some(m => m.body.operation === invalid.id && m.type === 'receipt'));
|
||||
assert.equal(journal().move.request.id,move.id, 'invalid Stop/Save and late Start must not displace reservation');
|
||||
assert.deepEqual(journal().actual,actual);
|
||||
}
|
||||
const cancel = message(action,'source',agent,revision,action === 'save' ? selected : {}); ui.send(cancel);
|
||||
await until(() => seen.some(m => m.body.operation === move.id && String(m.body.result).includes('cancelled before grant')));
|
||||
for (const data of held.splice(0)) send(data); await delay(100);
|
||||
assert.equal(journal().assignment.assignedHost,'source'); assert.equal(journal().assignment.chain,undefined);
|
||||
if(action === 'save') assert.deepEqual(journal().actual,actual);
|
||||
else await until(() => journal().phase === 'stopped');
|
||||
}
|
||||
assert.equal(seen.some(m => m.type === 'grant'),false);
|
||||
} finally {
|
||||
await source.close(); await target.close(); ui.close(); for(const c of server.clients) c.terminate();
|
||||
await new Promise<void>(resolve => server.close(() => resolve())); rmSync(dir,{ recursive:true,force:true });
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { host } from '../src/host.ts';
|
||||
import { relay } from '../src/relay.ts';
|
||||
import { managementClient } from '../src/intents.ts';
|
||||
import { connect } from '../src/client.ts';
|
||||
import { message, newKey, publicKey, open } from '../src/protocol.ts';
|
||||
import { writePrivate } from '../src/storage.ts';
|
||||
@@ -118,12 +119,12 @@ test('actual TUI: policy repair, bounded unchanged retry, lost committed receipt
|
||||
assert.equal(attempts.length, beforeReopen, 'reopen never republishes blocked intent');
|
||||
await ui.command('operations');
|
||||
if (loss === 'before-publication') {
|
||||
assert.match(ui.output, /1\. policy-host stop at revision 1: unknown/);
|
||||
assert.match(ui.output, new RegExp(`1\\. policy-host stop \\| agent ${agent} \\| operation ${operation.id} at revision 1: unknown`));
|
||||
await ui.command('reconcile'); await delay(150);
|
||||
assert.equal(attempts.length, beforeReopen, 'query is not a retry');
|
||||
await ui.command('retry 1', '[yes/no]: '); await ui.command('yes');
|
||||
}
|
||||
await until(() => ui.output.includes('policy-host stop: completed | accepted'));
|
||||
await until(() => ui.output.includes(`policy-host stop | agent ${agent} | operation ${operation.id}: completed | accepted`));
|
||||
assert.equal(journal().revision, 2); assert.equal(journal().phase, 'stopped');
|
||||
assert.equal(Object.keys(journal().operations).length, 2, 'one Start and original Stop only');
|
||||
assert.equal(journal().operations[operation.id].reply.body.result, 'accepted');
|
||||
@@ -146,3 +147,50 @@ test('actual TUI: policy repair, bounded unchanged retry, lost committed receipt
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('actual reopened TUI identifies both same-host/action/revision blocked agents in rows and retry confirmations', async () => {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-two-agent-recovery-')));
|
||||
const secret = newKey(), agents = [publicKey(newKey()), publicKey(newKey())];
|
||||
writePrivate(join(dir, 'identity.json'), { secret });
|
||||
const server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||
await new Promise<void>(resolve => server.once('listening', resolve));
|
||||
const address = server.address(); assert.ok(address && typeof address !== 'string');
|
||||
const url = `ws://127.0.0.1:${address.port}`;
|
||||
let attempts = 0;
|
||||
server.on('connection', socket => socket.on('message', raw => {
|
||||
if (open(JSON.parse(String(raw)), secret).type === 'stop') { attempts++; socket.close(1008, 'Fixture policy'); }
|
||||
}));
|
||||
const client = managementClient(join(dir, 'management-intents'), url, secret, () => {});
|
||||
let child: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await client.ready;
|
||||
const requests = agents.map(agent => message('stop', 'shared', agent, 0));
|
||||
for (const request of requests) client.submit(request);
|
||||
await until(() => client.status().every(o => o.retryAvailable)); client.close();
|
||||
const before = attempts;
|
||||
child = spawn(process.execPath, ['src/cli.ts', 'tui', join(dir, 'identity.json'), url], { stdio: ['pipe','pipe','pipe'] });
|
||||
let output = ''; child.stdout!.on('data', d => { output += String(d); }); child.stderr!.on('data', d => { output += String(d); });
|
||||
await until(() => output.includes('beehive> '));
|
||||
async function command(line: string, prompt = 'beehive> ') {
|
||||
const offset = output.length; child!.stdin!.write(`${line}\n`);
|
||||
await until(() => output.slice(offset).includes(prompt)); return output.slice(offset);
|
||||
}
|
||||
const rows = await command('operations');
|
||||
for (const request of requests) {
|
||||
const label = `shared stop | agent ${request.agent} | operation ${request.id}`;
|
||||
const row = rows.split('\n').find(line => line.includes(label)); assert.ok(row, rows);
|
||||
assert.ok(row.includes('at revision 0: unknown'));
|
||||
const number = row.match(/^(\d+)\./)?.[1]; assert.ok(number);
|
||||
const warning = await command(`retry ${number}`, '[yes/no]: ');
|
||||
assert.ok(warning.includes(`Retry original ${label} at revision 0 ONCE`), warning);
|
||||
await command('no');
|
||||
}
|
||||
child.stdin!.write('quit\n'); await until(() => child!.exitCode !== null);
|
||||
assert.equal(child.exitCode, 0, output); assert.equal(attempts, before, 'declined retry never publishes either agent');
|
||||
} finally {
|
||||
client.close();
|
||||
if (child && child.exitCode === null) { child.kill(); await new Promise<void>(resolve => child!.once('exit', () => resolve())); }
|
||||
for (const socket of server.clients) socket.terminate();
|
||||
await new Promise<void>(resolve => server.close(() => resolve())); rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline';
|
||||
import { mkdtempSync, realpathSync, writeFileSync, chmodSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
@@ -22,6 +22,8 @@ test('fixed Buzz CLI MCP rejects caller authority and owns CLI descendants on su
|
||||
fs.writeFileSync('identity', process.env.BUZZ_PRIVATE_KEY);
|
||||
const child = spawn(process.execPath, ['-e', "process.on('SIGTERM',()=>{});setTimeout(()=>{},15000)"], {stdio:'ignore'});
|
||||
fs.writeFileSync('descendant', String(child.pid));
|
||||
child.once('error', error => fs.writeFileSync('descendant-spawn-error', String(error.code)));
|
||||
child.once('spawn', () => fs.writeFileSync('descendant-spawned', JSON.stringify({ pid: child.pid, parent: process.pid })));
|
||||
process.stdin.resume();
|
||||
process.stdin.on('end', () => { ${mode === 'success' ? 'setTimeout(()=>process.exit(0),100)' : mode === 'failure' ? 'process.exit(2)' : "process.on('SIGTERM',()=>{});setTimeout(()=>process.exit(0),15000)"} });
|
||||
`);
|
||||
@@ -65,7 +67,20 @@ test('fixed Buzz CLI MCP rejects caller authority and owns CLI descendants on su
|
||||
}
|
||||
await tool.stop();
|
||||
assert.ok(absent(shim.pid!));
|
||||
if (existsSync(join(dir, 'descendant'))) assert.ok(absent(Number(readFileSync(join(dir, 'descendant'), 'utf8'))));
|
||||
if (existsSync(join(dir, 'descendant'))) {
|
||||
const raw = readFileSync(join(dir, 'descendant'), 'utf8'), pid = Number(raw);
|
||||
// Preserve the original assertion. A recurrence must distinguish an invalid
|
||||
// fixture PID from a visible PID; numeric identity alone is not ownership.
|
||||
let errno = 'visible';
|
||||
try { process.kill(pid, 0); } catch (error) { errno = String((error as NodeJS.ErrnoException).code); }
|
||||
if (errno !== 'ESRCH') {
|
||||
const read = (name: string) => existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : null;
|
||||
const processState = Number.isSafeInteger(pid) && pid > 0
|
||||
? spawnSync('/bin/ps', ['-o','state=,pgid=,ppid=,comm=','-p',String(pid)], { encoding:'utf8' }).stdout : null;
|
||||
console.error('Descendant exit observation', JSON.stringify({ mode, raw, pid, errno, spawned: read('descendant-spawned'), spawnError: read('descendant-spawn-error'), processState }));
|
||||
}
|
||||
assert.equal(errno, 'ESRCH', 'owned descendant must be absent after Stop (see failure capture)');
|
||||
}
|
||||
} finally { await tool.stop(); rmSync(dir, { recursive: true, force: true }); }
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user