feat(beehive): reconcile policy-blocked intents with informed unchanged retry

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-09 23:52:15 -04:00
parent 64305a7a00
commit 4fa4fd1469
9 changed files with 310 additions and 30 deletions

View File

@@ -5,6 +5,59 @@ Base `051c3a270be9c73da9ab06700bcab7d5552fceaa`; continuation starts at
`023c9274767ef50fa0f5b37ef1883336f8be59fd`. Candidate is the commit containing
this checkpoint (`git rev-parse HEAD`). No merge/release.
## Current published-candidate continuation 4341 — U1 policy recovery
R1 was separately committed and pushed as
`64305a7a0020da0faa599b46be37b538b9533945`; origin branch verified at that exact
head before this UX checkpoint. No reset/reimplementation or additional writer.
TUI now offers numbered `operations`, `reconcile`, and informed `retry <number>`.
Reconcile explicitly reconnects, reads relay history and queries host outbox via
existing inspect messages; it does not replay blocked work or assert policy repair.
Host inspect republishes durable terminal receipts so a receipt lost before relay
storage remains recoverable. Retry sends the original retained envelope ONCE with
unchanged ID/signature/ciphertext/body/fingerprint/revision. The durable policy block
is deliberately NOT removed: socket success does not authorize blind future retry.
Only a matched terminal receipt resolves pending work. Completed/known rejection/
UNKNOWN remain distinct, historical results are labelled separately from current
host inventory, and the confirmation explains policy repair, uncertain prior effect,
permanently invalid events and no remote cancellation. Repeated 1008 remains blocked.
No replacement ACK/control service, re-signing or host-precondition change.
Acceptance is **actual TUI subprocess**, real package client/relay/host, fresh keys
and fixture runner, with test-owned loopback WS fault placement (not a model or
production policy test):
- Policy denial before Stop publication -> explicit retry under continued denial ->
two denials total; correction/reopen sends zero blocked commands; reconcile sends
zero blocked commands; numbered confirmed retry completes original Stop once.
Three total attempts all byte-identical, one Start plus original Stop in host
operations, final stopped/revision2.
- Committed Stop receipt dropped BEFORE relay storage -> 1008 block -> corrected
reopen queries host outbox and recovers original accepted result without a second
Stop attempt. Final revision2; completed entries reject further retry.
- Reconcile after completion does not duplicate effects. Intentional Quit in each
subprocess exits 0 with independent-host-lifetime message, no spurious UNKNOWN.
- Existing lost-Start/crash/receipt negatives and installed cross-conversation seams
remain green; predecessor independent seven crypto/receipt/perms negatives reused.
Final semantic candidate: strict TypeScript + full installed-enabled package suite
**19/19 pass, zero skips**, 15.64s. Evidence in workspace
`WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/CONTINUATION_4341_EVIDENCE/ux-check.log` and
`ux-full.log`; production-bound regression source `test/policy-tui.test.ts`.
Binary hashes freshly rechecked and match the two pins below. No dependency changes.
No repo-wide just ci, GitHub CI approval, production admission, real provider/OAuth,
PR/merge/release, or full delivery claim. Source candidate is the commit containing
this section; publication record with exact head follows in the workspace evidence.
Configured Logan Johnson author/committer + DCO verified before push; no crypto signer.
Completed independent TOOLS_RECONNECT and INTENT_TUI reviews are consumed. The new
UX delta has self-review and executable acceptance, not yet a separate immutable
independent review. This is not a reason to leave the broader build waiting.
**ONE next executable step:** implement conservative same-identity two-host assignment /
Move with multi-host/agent selection, preserving remote-only normal configuration and
lifecycle and refusing unreachable-host takeover. Retention1000/pruning, profiles/key
lifecycle/Restart, broader harness parity and live-production gates remain unfinished.
## Continuation 4341 — R1 reconciliation
Recovered d12ed7b4e08501e2e5829da80689c9759b32bd49 plus the prior worker's
@@ -74,15 +127,14 @@ repeated). Prior installed runtime evidence retained; this run
also produced three verified fixture replies from agent
`b727bb8ae1e277a522f8dbad00aae15cc7b71e39c57d43e7f5d532529e8087aa`.
No real provider/admission calls, production activity, full repo CI or PR/merge/release.
Independent preceding broad-tool/reconnect review still in progress (evidence directory
exists; no completed report received). Existing B1/D1-D3 conclusions retained, not
Completed preceding broad-tool/reconnect review is consumed in continuation 4341 above. Existing B1/D1-D3 conclusions retained, not
reopened or treated as approval of this new journal delta.
Limits: relay URL is the community scope in this development topology, not a stable
production community identifier. Publication echo observation is session-local; terminal
host results persist. There is no relay per-event rejection/ACK protocol: policy failure
is conservatively UNKNOWN/blocked, not claimed failed; explicit guided recovery and
journal retention remain future UX work. Pre-upgrade host receipts lack fingerprints
is conservatively UNKNOWN/blocked, not claimed failed; guided recovery is now implemented in continuation 4341;
journal retention remains future work. Pre-upgrade host receipts lack fingerprints
and cannot complete new journal intents. Arbitrary disk rollback, malicious local
operator, simultaneous UI action coordination, log-full recovery and host SIGKILL
recovery are not proved. Single-operation exclusive files prevent conflicting-ID
@@ -91,9 +143,8 @@ process exit without cleanup, not machine power-loss proof. Stop-during-Start re
covered by unchanged full-suite regressions; no claim that this new test automates the
entire interactive crash workflow.
**ONE next executable step:** independent review and real interactive TUI recovery
acceptance of this client journal, especially policy-failure UNKNOWN recovery and
receipt ordering, before expanding to the two-host same-identity assignment/Move slice.
**Historical next step (completed in continuation 4341):** independent journal review
and real TUI policy-recovery acceptance. Current next step is two-host assignment/Move.
Full remaining parity scope below remains active, not declared complete.
## Recovery 4200a3ab
@@ -116,8 +167,8 @@ No recovered PID was used for cleanup and no private run artifacts were read.
New implementation commit `998e919a9b26af0ec3b15eefba539661380eeed7`
was pushed successfully as the first publication of `beehive/cbfd9440` to
`https://github.com/block/buzz.git`. This checkpoint-only follow-up retains that
source. No GitHub or NIP-34 PR has been created; repo-wide `just ci` and independent
review remain outstanding. `buzz pr open --help` requires a NIP-34 repository
source. No GitHub or NIP-34 PR has been created; repo-wide `just ci` remains outstanding; subsequent independent
reviews are consumed in continuation 4341. `buzz pr open --help` requires a NIP-34 repository
owner pubkey/id; neither was inferred from GitHub ownership. Any later Buzz PR
must carry channel `f45d3304-dcf0-44e8-a46d-bcd63b235fbc`.
@@ -139,7 +190,7 @@ run and single revision/operation survive. Stop still tears down the owned run;
intentional host/client close cannot reconnect. This proves relay-persisted
intent recovery, not recovery of controller intent that never reached the relay.
The existing receipt outbox is retained, not ACK-pruned. No protocol change or
new remote/local lifecycle endpoint. New delta requires independent review.
new remote/local lifecycle endpoint. This delta subsequently received TOOLS_RECONNECT review; R1 is resolved above.
## Implemented: ordinary multi-conversation Buzz CLI tool authority
@@ -205,7 +256,7 @@ B1 resolved through original/extended independent probes; strict/full12/12 and
independently verified installed signed publication. Its retained invariants are
host executable/env/key/relay ownership, childless shims, exact-model fencing,
distinct tool/ACP/publication evidence and bounded resources. This ordinary-CLI
delta has NOT yet received independent review. Long-lived request/session cap
delta subsequently received TOOLS_RECONNECT independent review. Long-lived request/session cap
recovery remains explicit subsequent work, not general MCP compatibility.
## Observable installed isolated outcome
@@ -272,10 +323,10 @@ cryptographic signing key configured, so no invented signer/signature claim.
## Remaining product scope / ONE next executable step
**Next:** independently review and exercise the local client-journal recovery
implemented above, including policy-failure UNKNOWN handling, then proceed to
two-host identity assignment/Move. The bounded journal is not full recovery/parity
completion. Independent multi-tool and reconnect review remains pending.
**Next:** conservative same-identity two-host assignment/Move and multi-host/agent
selection. Journal and multi-tool/reconnect independent reviews are completed and
consumed above; R1 and U1 corrections have executable acceptance. The bounded
journal is not full recovery/parity completion.
README retains the full parity matrix: reconnect/ACKs/crash recovery; multiple
hosts/agents/setups and S1 two-host identity; reusable profiles/metadata/history;

View File

@@ -37,7 +37,7 @@ On Block-managed machines, the public npm registry is policy-blocked. Append
to the install command to use the approved mirror. No credentials or persistent
registry config are needed. The committed lockfile is registry-agnostic.
TUI: `hosts`, `select <host-name>`, `show`, `start`, `save`, `stop`, `quit`.
TUI: `operations`, `reconcile`, `retry <number>`, `hosts`, `select <host-name>`, `show`, `start`, `save`, `stop`, `quit`.
Save asks for advertised model, workspace and independent behavior profile.
Only one fixture model and the `default` profile are currently supported;
profile authoring/versioning and useful multi-setup selection remain to build.
@@ -46,6 +46,34 @@ restart. `quit` leaves the host running. Reopen TUI to inspect it; `stop` confir
the owned process group is absent and leaves the agent assigned to this host.
Ctrl-C on **host** stops its owned fixture runner before exiting.
### Recover an UNKNOWN operation
`operations` lists numbered host/action/revision entries and historical results.
A terminal host rejection is **failed**; a matched accepted receipt is **completed**.
Neither a socket connection nor relay echo proves completion or current host state.
Use `hosts`/`show` for separately dated host inventory.
After a relay policy close (1008), unresolved work stays **UNKNOWN**, with automatic
retry disabled even after Quit/reopen. Repair policy outside the TUI, then:
1. `reconcile` reconnects and queries retained host receipts through the relay.
It does **not** resend policy-blocked operations or prove policy is repaired.
2. `operations` shows any recovered terminal result. If still UNKNOWN and retry
is appropriate, use `retry <number>` for the displayed operation, and confirm
`yes` after reading the warning. This attempts the original signed encrypted
event **once**, preserving its ID, fingerprint, body and revision preconditions.
3. A repeated denial stays blocked; another attempt requires another explicit
action. Even a successful socket send leaves UNKNOWN until a matched host receipt.
Retry does not clear the durable automatic-retry block, cancel remote work, mint a
replacement ID, update stale revisions, or re-sign an expired/invalid event. If the
original event can never be admitted, continue reconciliation; there is no honest
terminal result to invent. The development envelope has no built-in expiry, but
external policy may still reject it permanently. Keep the retained intent. Journal
retention (1,000 operations), pruning/log-full and host-crash recovery remain unfinished.
Ordinary transient loss still has bounded automatic unchanged replay. Quit changes
only UI transport lifetime, never the pending operation or host lifecycle.
**Do not point fixture mode at real agents.** This seam deliberately passes no
agent key, OAuth context, model, or instructions to the fixture. Executable trust
is a local operator decision; remote messages cannot supply executable/args/env.
@@ -100,8 +128,8 @@ assumes non-cloned, non-rollback, exclusively supervised installations.
- Relay history max 10,000 envelopes, per-envelope limit, slow-reader disconnect.
Log full closes publisher; no compaction. Outbox retained without ACK pruning;
host auto-reconnect replays saved receipts and relay history with operation-ID
deduplication (eight attempts/lifetime, bounded backoff). Relay ACKs and durable
TUI pending intents/reconnect are not yet implemented. Lost connection means
deduplication (eight attempts/lifetime, bounded backoff). Per-event relay ACKs remain absent. Durable
TUI pending intents/reconnect are implemented with a local encrypted journal. Lost connection means
unknown, not stopped or success; exhausted recovery needs operator attention.
- Inventory has freshness, not liveness proof. Host starts no action based on peer
agent presence. Only its locally bound agent is manageable.
@@ -205,7 +233,8 @@ Installed isolated validation covers two channels/threads and a later prompt in
the first thread, three signed replies under one agent identity, native channel
reads, non-member mention rejection and owner-only incoming dispatch. No local
reconfiguration or key generation occurs between prompts. This new surface still
needs independent delta review; see CHECKPOINT.md for exact evidence and limits.
has received independent tool/reconnect review; its R1 and later journal U1
recovery corrections are recorded in CHECKPOINT.md with exact evidence and limits.
Community admission/attestation and normal OAuth in the named host-service context
remain explicit local/operator actions. No live Databricks request has occurred.

View File

@@ -28,6 +28,21 @@ full suite passed 16/16; after status wording refinement strict + targeted TUI/c
5/5 passed, then final source strict/full default 15 pass + 1 opt-in skip. These fixtures do not establish production community admission, hostile
local storage/power loss or a polished manual recovery UX. See current CHECKPOINT.
## Policy recovery continuation 4341
`policy-tui.test.ts` drives actual Node TUI subprocesses over a fault proxy and the
real package relay/host with a fresh fixture runner. Before-publication Stop denial
remains UNKNOWN across reopen after repair; reconcile sends no blocked command;
numbered, informed retry sends the byte-identical envelope and resolves the original
Stop once. Repeated 1008 is bounded (one attempt per confirmation). A second journey
drops a committed receipt **before relay storage**, proving host outbox query rather
than merely cached relay history recovers it, with no extra lifecycle effect or
operation ID. Completed entries cannot be retried. Intentional Quit remains clean.
These are real client/relay/TUI subprocess seams, not a live model or production relay.
Independent d12ed7b4 crypto/receipt/perms and installed tool evidence remains reusable;
completed `INTENT_TUI_REVIEW_D12ED7B4.md` identifies U1, addressed by this delta.
The earlier pending/manual-recovery statements are historical, not a review wait.
## Automated production seams
- `slice.test.ts`: malformed/wrong-owner/tampered encryption envelopes; real
@@ -98,8 +113,8 @@ local storage/power loss or a polished manual recovery UX. See current CHECKPOIN
CLI error (returned without killing the session) all clean up the shim and any TERM-resistant in-group descendants.
Tests never open existing owner keys/profiles/cache, production services or native
client resources. With installed opt-in: 18/18 pass; without: 17 pass/1 explicit
skip. The installed fixture relay does not establish actual community admission,
client resources. R1 checkpoint with installed opt-in: 18/18 pass; without: 17 pass/1 explicit
skip. Current policy-recovery validation is recorded in CHECKPOINT. The installed fixture relay does not establish actual community admission,
current relay compatibility, provider/model attestation. Multi-thread delivery is isolated legacy-binary evidence only.
## Independent evidence and limits

View File

@@ -93,24 +93,39 @@ async function main() {
if (!prior || Number(m.body.observedAt) > Number(prior.body.observedAt)) inventory.set(m.host,m);
}
}, () => {
console.log(client.connected ? '\nManagement relay connected.' : '\nRelay disconnected: pending results UNKNOWN; reconnect attempts are bounded.');
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}`);
});
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, hosts, select <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, select <host>, show, save, start, stop, 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') { console.log(JSON.stringify(client.status(),null,2)); continue; }
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}` : ''}`));
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; }
if (line.startsWith('retry ')) {
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.`);
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'); }
}
continue;
}
if (line === 'hosts') { for (const [name,m] of inventory) console.log(`${name}: ${m.body.phase} | ${m.body.readiness} | ${Date.now()-Number(m.body.observedAt) > 6000 ? 'STALE/UNKNOWN' : 'recent host report'}`); continue; }
if (line.startsWith('select ')) { selected = line.slice(7); continue; }
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 hosts/select/show/save/start/stop/quit.'); continue; }
if (!['save','start','stop'].includes(line)) { console.log('Use operations/reconcile/retry <number>/hosts/select/show/save/start/stop/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') {

View File

@@ -44,6 +44,12 @@ export function connect(url: string, secret: string, receive: (m: Message) => vo
ready,
sendEnvelope(e: Envelope) { if (closed || socket.readyState !== WebSocket.OPEN) throw Error('Relay disconnected; result unknown'); socket.send(JSON.stringify(e)); },
send(m: Message) { if (closed || socket.readyState !== WebSocket.OPEN) throw Error('Relay disconnected; result unknown'); socket.send(JSON.stringify(seal(m,secret))); },
/** Explicit transport reconciliation; opening is never an operation result. */
reconnect() {
if (closed) throw Error('UI closed');
clearTimeout(retry); attempts = 0;
const previous = socket; socket = dial(); previous.close();
},
close() { closed = true; clearTimeout(retry); socket.close(); },
};
}

View File

@@ -93,7 +93,7 @@ export async function host(directory: string, url: string) {
if (closing || m.host !== setup.host || !['inspect','save','start','stop'].includes(m.type)) return;
// Serialized Stop processing durably resolves that Stop's own retraction.
if (m.type === 'stop') retracted.delete(m.id);
if (m.type === 'inspect') { publish(inventory()); return; }
if (m.type === 'inspect') { 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) {

View File

@@ -1,7 +1,7 @@
import { existsSync, mkdirSync, readdirSync, statSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { connect, validateRelayURL } from './client.ts';
import { digest, fields, object, open, publicKey, seal, type Envelope, type Message } from './protocol.ts';
import { digest, fields, message, object, open, publicKey, seal, type Envelope, type Message } from './protocol.ts';
import { readPrivate, writePrivate } from './storage.ts';
type Intent = { envelope: Envelope; request: Message; receipt?: Message; published: boolean; blocked?: boolean };
@@ -56,6 +56,11 @@ export function managementClient(root: string, url: string, secret: string, rece
for (const intent of intents.values()) if (!intent.receipt && !intent.blocked) {
try { transport.sendEnvelope(intent.envelope); } catch { break; } // Intent remains durable.
}
// Query host outbox too: relay history alone may lack a lost terminal receipt.
const hosts = new Map([...intents.values()].filter(i => !i.receipt).map(i => [i.request.host, i.request]));
for (const request of hosts.values()) {
try { transport.send(message('inspect', request.host, request.agent)); } catch { break; }
}
changed();
}
const transport = connect(url, secret, m => {
@@ -101,10 +106,22 @@ export function managementClient(root: string, url: string, secret: string, rece
intents.set(request.id, { request: immutable, envelope, published: false });
replay();
},
/** Read host results without lifting durable policy blocks or changing intent. */
reconcile() { if (closed) throw Error('UI closed'); transport.reconnect(); },
/** One informed attempt of the exact envelope. Keep the durable block even on
* success at the socket seam: only a matched terminal host receipt resolves it. */
retry(id: string) {
if (closed) throw Error('UI closed');
const intent = intents.get(id);
if (!intent || intent.receipt || !intent.blocked) throw Error('Only unresolved policy-blocked operations can be retried');
transport.sendEnvelope(intent.envelope);
changed();
},
status() {
return [...intents.values()].map(i => ({ request: structuredClone(i.request),
state: i.receipt ? (['accepted','saved; running configuration unchanged'].includes(String(i.receipt.body.result)) ? 'completed' : i.receipt.body.result === 'interrupted-reconcile-locally' ? 'unknown' : 'failed') : i.blocked ? 'unknown' : 'pending',
publication: i.blocked ? 'relay policy failure; automatic retry disabled' : i.receipt ? 'host terminal receipt' : i.published ? 'relay observed; not host admission' : 'unconfirmed',
retryAvailable: !i.receipt && !!i.blocked,
publication: i.receipt ? 'host terminal receipt' : i.blocked ? 'relay policy failure; automatic retry disabled; reconcile, then retry after policy repair' : i.published ? 'relay observed; not host admission' : 'unconfirmed',
result: i.receipt?.body.result }));
},
close() { closed = true; transport.close(); },

View File

@@ -107,7 +107,7 @@ test('process exit before network effects leaves an exact prepared envelope; cor
await ui.ready;
await until(() => ui.status()[0]?.publication === 'relay observed; not host admission');
const stored = JSON.parse(readFileSync(join(dir, 'relay.json'), 'utf8'));
assert.deepEqual(stored, [prepared], 'exact signed/encrypted event reused');
assert.deepEqual(stored.filter((e: unknown) => open(e, secret).type !== 'inspect'), [prepared], 'exact signed/encrypted event reused');
const sender = connect(url, secret, () => {}); await sender.ready;
sender.send(message('receipt', 'unrelated-host', request.agent, 1, { operation: request.id, fingerprint: 'f'.repeat(64), result: 'accepted' }));
await delay(80); sender.close();
@@ -116,7 +116,7 @@ test('process exit before network effects leaves an exact prepared envelope; cor
await until(() => ui.status()[0]?.state === 'unknown');
ui.close();
let republished = 0;
server.on('connection', socket => socket.on('message', () => { republished++; }));
server.on('connection', socket => socket.on('message', data => { if (open(JSON.parse(String(data)), secret).type !== 'inspect') republished++; }));
ui = managementClient(root, url, secret, () => {}); await ui.ready;
await delay(150);
assert.equal(ui.status()[0]?.state, 'unknown');

View File

@@ -0,0 +1,147 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, realpathSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
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 { connect } from '../src/client.ts';
import { message, newKey, publicKey, open } from '../src/protocol.ts';
import { writePrivate } from '../src/storage.ts';
async function until(predicate: () => boolean) {
for (let n = 0; n < 240; n++) { if (predicate()) return; await delay(25); }
throw Error('Missing real TUI recovery evidence');
}
test('actual TUI: policy repair, bounded unchanged retry, lost committed receipt reconciliation and Quit', async () => {
for (const loss of ['before-publication', 'after-commit'] as const) {
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-policy-tui-')));
const secret = newKey(); const agentSecret = newKey(); const agent = publicKey(agentSecret);
writePrivate(join(dir, 'identity.json'), { secret });
writePrivate(join(dir, 'setup.json'), { host: 'policy-host', ownerSecret: secret, agentSecret,
runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' });
const server = await relay(0, publicKey(secret), join(dir, 'relay.json'));
const address = server.address(); assert.ok(address && typeof address !== 'string');
const url = `ws://127.0.0.1:${address.port}`;
const h = await host(dir, url);
const control = connect(url, secret, () => {}); await control.ready;
const journal = () => JSON.parse(readFileSync(join(dir, 'journal.json'), 'utf8'));
control.send(message('start', 'policy-host', agent, 0));
await until(() => journal().phase === 'running');
let deny = true; let denials = 0;
const attempts: string[] = [];
const upstreams = new Set<WebSocket>();
const proxy = new WebSocketServer({ host: '127.0.0.1', port: 0 });
await new Promise<void>(resolve => proxy.once('listening', resolve));
proxy.on('connection', downstream => {
const upstream = new WebSocket(url); upstreams.add(upstream);
const queued: string[] = [];
upstream.on('open', () => { for (const data of queued) upstream.send(data); });
downstream.on('message', raw => {
const data = String(raw); const m = open(JSON.parse(data), secret);
if (m.type === 'stop') {
attempts.push(data);
if (deny && loss === 'before-publication') { denials++; downstream.close(1008, 'Fixture policy'); return; }
}
if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else queued.push(data);
});
upstream.on('message', raw => {
const data = String(raw); const m = open(JSON.parse(data), secret);
if (downstream.readyState === WebSocket.OPEN) downstream.send(data);
});
upstream.on('error', () => {}); downstream.on('error', () => {});
downstream.on('close', () => { upstream.close(); upstreams.delete(upstream); });
});
if (loss === 'after-commit') {
// Lose the committed Stop receipt BEFORE relay storage, not just at the UI.
// Recovery must query the real host outbox; relay history is insufficient.
const hostSocket = [...server.clients][0]!;
const handlers = hostSocket.listeners('message'); hostSocket.removeAllListeners('message');
hostSocket.on('message', (raw, binary) => {
const m = open(JSON.parse(String(raw)), secret);
if (deny && m.type === 'receipt' && attempts.length && m.body.operation === open(JSON.parse(attempts[0]!), secret).id) {
assert.equal(journal().phase, 'stopped'); denials++;
for (const socket of proxy.clients) socket.close(1008, 'Fixture committed receipt loss');
return;
}
for (const handler of handlers) Reflect.apply(handler, hostSocket, [raw, binary]);
});
}
const proxyAddress = proxy.address(); assert.ok(proxyAddress && typeof proxyAddress !== 'string');
const proxyUrl = `ws://127.0.0.1:${proxyAddress.port}`;
const children: ReturnType<typeof spawn>[] = [];
function tui() {
const child = spawn(process.execPath, ['src/cli.ts', 'tui', join(dir, 'identity.json'), proxyUrl], { stdio: ['pipe','pipe','pipe'] });
children.push(child);
let output = ''; child.stdout!.on('data', d => { output += String(d); }); child.stderr!.on('data', d => { output += String(d); });
return {
get output() { return output; },
async ready() { await until(() => output.includes('beehive> ')); },
async command(line: string, expected = 'beehive> ') {
const offset = output.length; child.stdin!.write(`${line}\n`);
await until(() => output.slice(offset).includes(expected));
},
async quit() {
const offset = output.length; child.stdin!.write('quit\n');
await until(() => child.exitCode !== null);
assert.equal(child.exitCode, 0, output);
assert.match(output.slice(offset), /UI closed; host lifetime is independent/);
assert.doesNotMatch(output.slice(offset), /UNKNOWN|disconnected/);
},
};
}
try {
let ui = tui(); await ui.ready();
await ui.command('select policy-host');
await ui.command('stop');
await until(() => ui.output.includes('automatic retry disabled'));
const original = attempts[0]; assert.ok(original);
const operation = open(JSON.parse(original), secret);
if (loss === 'before-publication') {
assert.equal(journal().phase, 'running');
await ui.command('reconcile'); await delay(150);
await ui.command('retry 1', '[yes/no]: '); await ui.command('yes');
await until(() => denials === 2); await delay(250);
assert.equal(attempts.length, 2, 'repeated policy denial never loops');
assert.equal(journal().phase, 'running');
} else {
assert.equal(journal().phase, 'stopped'); assert.equal(journal().revision, 2);
}
await ui.quit(); deny = false;
const beforeReopen = attempts.length;
ui = tui(); await ui.ready(); await delay(200);
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/);
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'));
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');
assert.ok(attempts.every(e => e === original), 'ID, signature, ciphertext, body and preconditions are byte-identical');
const completedAttempts = attempts.length;
await ui.command('retry 1');
assert.match(ui.output, /Use operations to choose an unresolved policy-blocked operation/);
assert.equal(attempts.length, completedAttempts);
await ui.command('reconcile'); await delay(150);
assert.equal(journal().revision, 2, 'reconcile cannot duplicate lifecycle effects');
await ui.quit();
console.log(`${loss}: exact-envelope attempts=${attempts.length}, denials=${denials}, revision=2, original Stop accepted; Quit clean`);
} finally {
for (const child of children) if (child.exitCode === null) { child.kill(); await new Promise<void>(resolve => child.once('exit', () => resolve())); }
control.close(); await h.close();
for (const s of proxy.clients) s.terminate(); for (const s of upstreams) s.terminate();
await new Promise<void>(resolve => proxy.close(() => resolve()));
for (const s of server.clients) s.terminate(); await new Promise<void>(resolve => server.close(() => resolve()));
rmSync(dir, { recursive: true, force: true });
}
}
});