diff --git a/beehive/CHECKPOINT.md b/beehive/CHECKPOINT.md index 3cd5c21313..27451d5276 100644 --- a/beehive/CHECKPOINT.md +++ b/beehive/CHECKPOINT.md @@ -1,3 +1,86 @@ +## Recovery 5360 — final validation / publication candidate + +Recovered base HEAD: 425feb343e07f5d36fb7ee18b9502c0fd172b15d. The source +candidate is the commit containing this section; all surviving predecessor edits +were preserved and completed, not reset or duplicated. + +**Explicit relay-review R1: CLOSED.** CLI consumes relay close callback, drains +queued persistence, surfaces sanitized possible durability uncertainty and exits 1; +actual subprocess directory-fault (wire1011 + possible replacement) and clean +SIGINT exit0 controls pass. No storage-fence/uncertain-vs-rejected change. + +Final strict PASS; exactly ONE FULL DEFAULT concurrent installed-enabled suite +**78/78, zero skips/cancellations, natural 44.186s**, on Node 24.15.0/pnpm 11.4.0. +Both host CLI service journeys, pre-ready SIGINT/SIGTERM and policy driver pass. +No serial mask, retry-to-green, timeout increase, force exit or weak assertions. +No executable edits after this run; only acceptance documentation. The prior +71/73 broker/reply-tool failures and older disconnect/admission observations +remain separately scoped, not classified by nonrecurrence. Independent diagnosis +and normal-transition review operate the immutable base and remain to consume. + +Self-review: one natural host close promise owns startup abort and ready shutdown; +all slots drain with allSettled, uncertain teardown retains lock, and CLI awaits +errors. No PID-derived cleanup authority. Policy barrier is public inventory for +exact host/agent/revision, not sleep or operation retry; product CAS unchanged. +Recovered service test uses actual CLI from wizard installation, never direct host +construction. Captured child output is capped at 8192 characters. +Evidence directory: WORK_LOGS/BEEHIVE_DESIGN_183FFAF0/RECOVERY_5360, including +final-strict.log, full-installed-default.log and separate source/installed hashes. +Installed pins unchanged (buzz-acp 10612d00…441ea; Buzz CLI 147cc2cc…766bb2af5a37b856c5). +Configured Logan Johnson author/committer + DCO; no invented +cryptographic signer. Publication verification recorded in that evidence directory. + +Remaining: independent findings reconciliation, actual Goose/provider login and +production service/current kind40002 admission; Claude/Codex/providers/presets/ +custom/mesh/compute and full parity. Installed Goose is a deterministic TS fixture, +not real Goose or live LLM proof. No owner credentials, OAuth caches, private run +journals, provider calls, production operations, Rust/native/client changes, repo +CI, merge, release or full-product claim. + +## Recovery 5360 — pre-final exact candidate gate + +R1 CLOSED locally: actual CLI fault/clean SIGINT 2/2 + strict on pinned +Node 24.15.0/pnpm 11.4.0. Startup/policy focused 8/8 (5.349s): actual +SIGINT/SIGTERM before withheld upgrade ready releases clean lock, unchanged +journal, subsequent startup/ready shutdown; quarantine keeps lock and double +close shares teardown. No reconnect/deadline changes. +Policy driver now waits for relevant PUBLIC host+agent running inventory rev1 +before spawning Stop TUI. This source-grounded bounded barrier preserves all +CAS/unknown/retry/operation-count assertions; pinned runtime reproduces accepted +unchanged original Stop at rev2 in both loss modes. Not a product recovery fix. +Recovered service subprocess journeys PASS 2/2 (38.142s): wizard-produced initial +normal and selected-B NEW immutable conversion, actual host CLI subprocess, +installed buzz-acp/Buzz CLI, four signed same-agent replies including Restart, +model/profile/history/Y assertions. Goose is still a TS protocol fixture, no +live provider/current-kind40002 admission or production service deployment. +Bounded recovered test stdout/stderr tails to 8 KiB. Evidence RECOVERY_5360/ +{r1,startup-policy,service}.log; older CLI_333D partial log retained unchanged. +Next: strict + ONE FULL DEFAULT concurrent installed-enabled run of this exact +executable candidate, no serial masking/retries/deadline increase/force exit. +Historical full 71/73 and older failures remain separately scoped. + +## Recovery 5360 — preserved working state, before validation + +Recovered HEAD 425feb343e07f5d36fb7ee18b9502c0fd172b15d; no new commits. +Preserved prior edits to cli.ts, host.ts, normal-conversation/startup-transport tests, +and untracked cli-shutdown.test.ts / relay-cli-fault.ts. No reset/cleanup. +R1 patch already present (callback diagnostic/nonzero exit); prior local 2/2 claim +retained below, fresh pinned validation pending. Startup abort and actual service +journey edits also survived; not yet accepted. Policy public-inventory driver fix +was absent and is next after R1 validation. Evidence: CLI_333D existing public logs; +new evidence will be in RECOVERY_5360. No private run journals consulted. + +## CLI lifecycle continuation 333d — early R1 checkpoint + +425feb actual CLI still discarded relay.close errors: R1 was OPEN, not fixed by +normal wizard work. Now the real CLI consumes close callback, reports sanitized +possible durability uncertainty, exits 1, and leaves history/fences intact. Clean +SIGINT exits 0. Actual CLI subprocess regressions (test-only module load injection +at existing directory boundary, no copied CLI) pass 2/2, strict passes on Node +24.15.0/pnpm 11.4.0. Replacement present + wire1011 is NOT durable rejection or +accepted data loss. R1 implementation/regression CLOSED locally; final gate and +publication pending. Startup signals and service subprocess work follow. + # Beehive executable checkpoint ## Normal conversation wizard A661 — final gate / scoped handoff diff --git a/beehive/TESTING.md b/beehive/TESTING.md index 499b7b8229..07a456b3bd 100644 --- a/beehive/TESTING.md +++ b/beehive/TESTING.md @@ -514,3 +514,21 @@ no secret echo, retained assignment, 0600). Initial/imported executable paths he are deliberately metadata-only fixtures; no provider/login or runtime spawn claimed. Existing Goose/assignment tests retain actual standby Start denial and secure-input negatives. The normal installed journeys run only with the existing installed opt-in. + +## CLI lifecycle recovery 5360 + +`cli-shutdown.test.ts` launches the actual relay CLI. Test-only module loading +injects the existing directory persistence boundary: wire1011 and possible +replacement must lead to uncertainty stderr and SIGINT exit1, versus clean exit0. +`startup-transport.test.ts` adds actual host CLI SIGINT/SIGTERM while upgrade is +withheld, clean lock/journal/restart checks, quarantine retention and double close. +`policy-tui.test.ts` now waits for relevant PUBLIC running rev1 inventory before +spawning Stop TUI; private journal rev1 is not asynchronous relay visibility. +Original unknown/byte-identical retry/accepted rev2 assertions remain unchanged. +`normal-conversation.test.ts` now starts actual host CLI subprocesses from the +wizard-produced installations instead of in-process host construction. Initial +normal and selected-B conversion still require four signed installed CLI replies +including Restart, same identity, exact model/profile and unchanged sibling/history. +Strict + one default concurrent installed-enabled full run: 78/78, natural 44.186s. +This closes scoped service-subprocess continuity, not real Goose/provider or +production kind40002/service deployment proof. See CHECKPOINT for historical failures. diff --git a/beehive/src/cli.ts b/beehive/src/cli.ts index 49acaa718d..19b4fa3e4e 100644 --- a/beehive/src/cli.ts +++ b/beehive/src/cli.ts @@ -195,12 +195,33 @@ async function main() { } else if (command === 'relay') { const server = await relay(Number(args[0]),text(args[1]),resolve(text(args[2]))); console.log(`Development relay listening ${JSON.stringify(server.address())}`); - process.once('SIGINT',() => { for (const c of server.clients) c.close(); server.close(); }); + let closing = false; + const close = () => { + if (closing) return; closing = true; + for (const c of server.clients) c.close(); + server.close(error => { + if (error) { console.error('Relay shutdown failed; storage durability may be uncertain. Retain history for reconciliation.'); process.exitCode = 1; } + }); + }; + process.on('SIGINT', close); process.on('SIGTERM', close); } else if (command === 'host') { - const running = await host(resolve(text(args[0])),text(args[1])); - console.log(`Host online; agents ${running.agents.join(', ')}. Ctrl-C stops owned runner before host exits.`); - process.once('SIGINT',() => { void running.close(); }); - process.once('SIGTERM',() => { void running.close(); }); + const controller = new AbortController(); + const stop = () => controller.abort(); + process.on('SIGINT', stop); process.on('SIGTERM', stop); + try { + const running = await host(resolve(text(args[0])), text(args[1]), controller.signal); + console.log(`Host online; agents ${running.agents.join(', ')}. Ctrl-C stops owned runner before host exits.`); + // The host owns teardown both before and after ready. Observe its one + // completion promise so uncertain teardown cannot disappear as a rejection. + await new Promise(resolve => { + if (controller.signal.aborted) resolve(); + else controller.signal.addEventListener('abort', () => resolve(), { once: true }); + }); + await running.close(); + } catch (error) { + if (!controller.signal.aborted || error !== controller.signal.reason) throw error; + // Transport ready rejection caused by intentional startup cancellation. + } finally { process.off('SIGINT', stop); process.off('SIGTERM', stop); } } else if (command === 'tui') { const secret = text(object(readPrivate(resolve(text(args[0])))).secret); const inventory = new Map(); diff --git a/beehive/src/host.ts b/beehive/src/host.ts index 047532fb39..6252229a6e 100644 --- a/beehive/src/host.ts +++ b/beehive/src/host.ts @@ -501,7 +501,8 @@ function slot(setup: Setup, path: string, agent: string, currentSetup: (id: stri } /** One installation owns every slot, lock and management transport. */ -export async function host(directory: string, url: string) { +export async function host(directory: string, url: string, signal?: AbortSignal) { + signal?.throwIfAborted(); validateRelayURL(url); const lock = join(directory, 'host.lock'); mkdirSync(lock, { mode: 0o700 }); // Never infer ownership from a recovered PID. @@ -510,6 +511,18 @@ export async function host(directory: string, url: string) { const slots = new Map>(); const profiles = new Profiles(); const publish = (m: Message) => { try { client?.send(m); } catch { /* Slot outbox survives transport loss. */ } }; + let heartbeat: ReturnType | undefined; + let closing: Promise | undefined; + const close = () => closing ??= (async () => { + initialized = false; client?.close(); clearInterval(heartbeat); + const results = await Promise.allSettled([...slots.values()].map(s => s.close())); + const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); + if (errors.length) throw new AggregateError(errors, 'Incomplete owned teardown; installation remains locked'); + rmdirSync(lock); + })(); + // Abort uses the same owner as ready shutdown, including quarantine fences. + const abort = () => { void close().catch(() => {}); }; + signal?.addEventListener('abort', abort, { once: true }); try { const entries = installationSlots(directory); for (const entry of entries) { @@ -535,19 +548,17 @@ export async function host(directory: string, url: string) { else if (['save','start','restart','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; } + signal?.throwIfAborted(); + } catch (e) { + try { await close(); } finally { signal?.removeEventListener('abort', abort); } + if (signal?.aborted) throw signal.reason; + throw e; + } initialized = true; for (const s of slots.values()) s.replay(); - const heartbeat = setInterval(() => { for (const s of slots.values()) s.heartbeat(); }, 2000); - let closing: Promise | undefined; + heartbeat = setInterval(() => { for (const s of slots.values()) s.heartbeat(); }, 2000); return { agent: [...slots.keys()][0]!, agents: [...slots.keys()], close() { - return closing ??= (async () => { - initialized = false; client?.close(); clearInterval(heartbeat); - // allSettled starts every teardown and preserves each slot's durable truth. - const results = await Promise.allSettled([...slots.values()].map(s => s.close())); - const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); - if (errors.length) throw new AggregateError(errors, 'Incomplete owned teardown; installation remains locked'); - rmdirSync(lock); - })(); + signal?.removeEventListener('abort', abort); + return close(); } }; } diff --git a/beehive/test/cli-shutdown.test.ts b/beehive/test/cli-shutdown.test.ts new file mode 100644 index 0000000000..fcef6c5109 --- /dev/null +++ b/beehive/test/cli-shutdown.test.ts @@ -0,0 +1,35 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { WebSocket } from 'ws'; +import { newKey, publicKey, seal, message } from '../src/protocol.ts'; + +for (const fault of [false, true]) test(`actual relay CLI SIGINT surfaces uncertainty=${fault}`, { timeout: 10000 }, async () => { + const dir = mkdtempSync(join(tmpdir(), 'beehive-cli-close-')); + const secret = newKey(), path = join(dir, 'history.json'); + const child = spawn(process.execPath, [...(fault ? ['--import', './test/relay-cli-fault.ts'] : []), 'src/cli.ts', 'relay', '0', publicKey(secret), path], { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = '', errors = ''; child.stdout.on('data', b => output = (output + b).slice(-8192)); child.stderr.on('data', b => errors = (errors + b).slice(-8192)); + const exited = once(child, 'exit'); + let ws: WebSocket | undefined; + try { + while (!output.includes('\n')) await once(child.stdout, 'data'); + const address = JSON.parse(output.trim().replace('Development relay listening ', '')); + ws = new WebSocket(`ws://127.0.0.1:${address.port}`); await once(ws, 'open'); + const observed = once(ws, fault ? 'close' : 'message'); + const envelope = seal(message('inspect', 'h', 'a'), secret); ws.send(JSON.stringify(envelope)); + const result = await observed; + if (fault) assert.equal(result[0], 1011); + assert.deepEqual(JSON.parse(readFileSync(path, 'utf8')), [envelope], 'replacement is possible, not a durable rejection'); + child.kill('SIGINT'); + assert.deepEqual(await exited, [fault ? 1 : 0, null]); + if (fault) assert.match(errors, /storage durability may be uncertain/); else assert.equal(errors, ''); + } finally { + ws?.terminate(); + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await exited; } + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/beehive/test/normal-conversation.test.ts b/beehive/test/normal-conversation.test.ts index 4cf3e8001b..1c19e835bd 100644 --- a/beehive/test/normal-conversation.test.ts +++ b/beehive/test/normal-conversation.test.ts @@ -10,7 +10,8 @@ import { schnorr } from '@noble/curves/secp256k1'; import { setTimeout as delay } from 'node:timers/promises'; import { terminal } from './local-terminal.ts'; import { installationSlots } from '../src/slots.ts'; -import { host } from '../src/host.ts'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; import { relay } from '../src/relay.ts'; import { readPrivate, writePrivate } from '../src/storage.ts'; import { pathToFileURL } from 'node:url'; @@ -19,7 +20,7 @@ import { profileRevision } from '../src/profiles.ts'; import { newKey, publicKey, message, type Message } from '../src/protocol.ts'; // Explicitly opt-in installed executable; never opens an owner profile or provider. -for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart + production host + installed CLI signed replies (${converting ? 'selected diagnostic B conversion' : 'initial normal default'})`, { skip: !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => { +for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart + host CLI service subprocess + installed CLI signed replies (${converting ? 'selected diagnostic B conversion' : 'initial normal default'})`, { skip: !process.env.BEEHIVE_REAL_BUZZ_ACP }, async () => { const goose = true; const dir = realpathSync(mkdtempSync(join(tmpdir(), 'bh-installed-'))); writeFileSync(join(dir, 'mode'), 'ok'); @@ -67,7 +68,13 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart { prompt: 'Save NEW Goose binding only', answer: 'yes' }, ]); assert.ok(!setupOutput.includes(ownerSecret)); - const state = () => readPrivate(entry.path) as any; + // Lifecycle decisions come from signed public inventory, not a private journal. + let inventory: Message | undefined; + const state = () => { + assert.ok(inventory, 'public host inventory required'); + return { revision: inventory.revision, phase: inventory.body.phase, + selected: inventory.body.selectedNext as any, actual: inventory.body.actualRun as any }; + }; const channel = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; const replies: any[] = []; const subscriptionsBySocket = new Map(); @@ -138,7 +145,31 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart const management = await relay(0, owner, join(dir, 'management.json')); const ma = management.address(); assert.ok(ma && typeof ma !== 'string'); const managementURL = `ws://127.0.0.1:${ma.port}`; - let h = await host(installation, managementURL); + const observer = connect(managementURL, ownerSecret, m => { + if (m.type === 'inventory' && m.host === 'journey' && m.agent === agent) inventory = m; + }); + await observer.ready; + const service = async () => { + const child = spawn(process.execPath, ['src/cli.ts', 'host', installation, managementURL], { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = '', errors = ''; + child.stdout.on('data', b => output = (output + b).slice(-8192)); child.stderr.on('data', b => errors = (errors + b).slice(-8192)); + const exited = once(child, 'exit'); + let closing: Promise | undefined; + const close = () => closing ??= (async () => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + assert.deepEqual(await exited, [0, null], errors); + assert.equal(errors, ''); + assert.equal(existsSync(join(installation, 'host.lock')), false); + })(); + try { + for (let n = 0; n < 400 && !(output.includes('Host online;') && inventory); n++) { + assert.equal(child.exitCode, null, errors); await delay(20); + } + assert.match(output, /Host online;/); assert.ok(inventory); + return { close }; + } catch (error) { await close(); throw error; } + }; + let h = await service(); if (converting) { await terminal(['tui', identity, managementURL], [ { prompt: 'beehive> ', answer: 'select 1' }, @@ -163,7 +194,7 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart assert.deepEqual(readFileSync(entry.path), beforeJournal); assert.deepEqual(installationSlots(installation)[0]!.bindings.B, beforeB); assert.deepEqual(installationSlots(installation)[0]!.bindings.default, originalA); - h = await host(installation, managementURL); + inventory = undefined; h = await service(); await terminal(['tui', identity, managementURL], [ { prompt: 'beehive> ', answer: 'select 1' }, { prompt: 'beehive> ', answer: 'binding B-normal', observedRevision: 3 }, @@ -234,7 +265,7 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart ]); assert.notEqual(state().actual.evidence.session, oldActual.evidence.session); assert.equal(state().actual.evidence.model, oldActual.evidence.model); - assert.deepEqual(state().runs[oldActual.run], oldActual); + assert.deepEqual((readPrivate(entry.path) as any).runs[oldActual.run], oldActual, 'fresh fixture history audit only; never used to admit execution'); assert.deepEqual(readFileSync(join(installation, 'setup.json')), manifest); assert.deepEqual(readFileSync(sibling.path), originalY); assert.deepEqual(readFileSync(join(installation, 'setup.json')), convertedManifest); @@ -274,6 +305,7 @@ for (const converting of [false, true]) test(`normal wizard + TUI Start/Restart } finally { if (goose && existsSync(join(dir, 'goose-rpc-methods'))) console.log(readFileSync(join(dir, 'goose-rpc-methods'), 'utf8')); + observer.close(); await session.stop(); for (const file of ['harness-pid', 'descendant-pid', 'tool-shim-pid']) { if (!existsSync(join(dir, file))) continue; diff --git a/beehive/test/policy-tui.test.ts b/beehive/test/policy-tui.test.ts index d6cf75b327..555e68e6cc 100644 --- a/beehive/test/policy-tui.test.ts +++ b/beehive/test/policy-tui.test.ts @@ -30,10 +30,16 @@ test('actual TUI: policy repair, bounded unchanged retry, lost committed receipt 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; + let relayVisibleRunning = false; + const control = connect(url, secret, m => { + // A private journal commit is not yet public relay inventory visibility. + if (m.type === 'inventory' && m.host === 'policy-host' && m.agent === agent && m.revision === 1 && m.body.phase === 'running') relayVisibleRunning = true; + }); 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'); + // Later TUI connections replay this durably committed revision before Stop. + await until(() => relayVisibleRunning); let deny = true; let denials = 0; const attempts: string[] = []; const upstreams = new Set(); diff --git a/beehive/test/relay-cli-fault.ts b/beehive/test/relay-cli-fault.ts new file mode 100644 index 0000000000..c798144067 --- /dev/null +++ b/beehive/test/relay-cli-fault.ts @@ -0,0 +1,11 @@ +// Test-only module instrumentation: execute the real CLI, inject only the existing +// storage boundary. No production environment switch or copied shutdown owner. +import { registerHooks } from 'node:module'; +registerHooks({ load(url, context, nextLoad) { + const loaded = nextLoad(url, context); + if (!url.endsWith('/src/relay.ts')) return loaded; + const source = String(loaded.source); + const needle = 'boundary?: SnapshotBoundary'; + if (!source.includes(needle)) throw Error('Relay boundary instrumentation drift'); + return { ...loaded, source: source.replace(needle, "boundary: SnapshotBoundary = async phase => { if (phase === 'directory') throw Error('injected directory fault'); }") }; +} }); diff --git a/beehive/test/startup-transport.test.ts b/beehive/test/startup-transport.test.ts index b0bd0e89da..cef8230999 100644 --- a/beehive/test/startup-transport.test.ts +++ b/beehive/test/startup-transport.test.ts @@ -98,3 +98,62 @@ test('initial HTTP policy denial fails explicitly without automatic retry even w assert.equal(e.upgrades, 1); assert.equal(recovered, 0); } finally { client.close(); await e.close(); } }); + +for (const signal of ['SIGINT', 'SIGTERM'] as const) test(`actual host CLI ${signal} during withheld upgrade drains startup; subsequent ready shutdown`, { timeout: 10000 }, async () => { + const { spawn } = await import('node:child_process'); + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-cli-startup-'))); + const secret = newKey(), agentSecret = newKey(); + const e = await endpoint(); + provision(dir, { host: 'startup', ownerSecret: secret, agentSecret, runner: process.execPath, + args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }, + createGenesis(publicKey(secret), publicKey(agentSecret), 'startup')); + const before = readFileSync(join(dir, 'journal.json')); + const launch = () => { + const child = spawn(process.execPath, ['src/cli.ts', 'host', dir, e.url], { stdio: ['ignore', 'pipe', 'pipe'] }); + let output = '', errors = ''; + child.stdout.on('data', b => output = (output + b).slice(-8192)); child.stderr.on('data', b => errors = (errors + b).slice(-8192)); + return { child, exited: once(child, 'exit'), get output() { return output; }, get errors() { return errors; } }; + }; + let running = launch(); + try { + await until(() => e.upgrades === 1); + assert.ok(existsSync(join(dir, 'host.lock'))); + running.child.kill(signal); + assert.deepEqual(await running.exited, [0, null], running.errors); + assert.equal(running.errors, ''); + assert.equal(existsSync(join(dir, 'host.lock')), false); + assert.deepEqual(readFileSync(join(dir, 'journal.json')), before); + assert.equal(e.upgrades, 1); + e.mode = 'accept'; running = launch(); + await until(() => running.output.includes('Host online;')); + running.child.kill(signal); + assert.deepEqual(await running.exited, [0, null], running.errors); + assert.equal(existsSync(join(dir, 'host.lock')), false); + assert.deepEqual(readFileSync(join(dir, 'journal.json')), before); + } finally { + if (running.child.exitCode === null && running.child.signalCode === null) { running.child.kill('SIGTERM'); await running.exited; } + await e.close(); rmSync(dir, { recursive: true, force: true }); + } +}); + +test('startup abort retains quarantine lock; ready double close shares teardown', async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-abort-fence-'))); + const secret = newKey(), agentSecret = newKey(); + const e = await endpoint(); + const { writePrivate, readPrivate } = await import('../src/storage.ts'); + try { + provision(dir, { host: 'startup', ownerSecret: secret, agentSecret, runner: process.execPath, + args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' }, + createGenesis(publicKey(secret), publicKey(agentSecret), 'startup')); + e.mode = 'accept'; const clean = await host(dir, e.url); + const one = clean.close(); assert.equal(clean.close(), one); await one; + const path = join(dir, 'journal.json'); + const state = readPrivate(path) as Record; + state.phase = 'quarantined'; writePrivate(path, state); + e.mode = 'stall'; const controller = new AbortController(); + const pending = host(dir, e.url, controller.signal); + const rejected = assert.rejects(pending, /Incomplete owned teardown/); + await until(() => e.upgrades === 2); controller.abort(); await rejected; + assert.equal(existsSync(join(dir, 'host.lock')), true, 'unproven execution remains fenced'); + } finally { await e.close(); rmSync(dir, { recursive: true, force: true }); } +});