@crouton-kit/crouter 0.3.19 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-personas/design/PERSONA.md +1 -0
- package/dist/builtin-personas/design/orchestrator.md +1 -0
- package/dist/builtin-personas/developer/PERSONA.md +1 -0
- package/dist/builtin-personas/developer/orchestrator.md +1 -0
- package/dist/builtin-personas/explore/PERSONA.md +1 -0
- package/dist/builtin-personas/explore/orchestrator.md +4 -0
- package/dist/builtin-personas/general/PERSONA.md +1 -0
- package/dist/builtin-personas/general/orchestrator.md +4 -0
- package/dist/builtin-personas/plan/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/orchestrator.md +1 -0
- package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
- package/dist/builtin-personas/review/PERSONA.md +1 -0
- package/dist/builtin-personas/review/orchestrator.md +4 -0
- package/dist/builtin-personas/spec/PERSONA.md +1 -0
- package/dist/builtin-personas/spec/orchestrator.md +1 -0
- package/dist/commands/human/queue.js +11 -0
- package/dist/commands/node.js +4 -1
- package/dist/core/__tests__/broker-attach-limits.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
- package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
- package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
- package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dialogs.test.js +126 -0
- package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
- package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
- package/dist/core/__tests__/helpers/broker-clients.js +178 -0
- package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
- package/dist/core/__tests__/live-mutation.test.js +4 -147
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/runtime/launch.d.ts +9 -2
- package/dist/core/runtime/launch.js +20 -3
- package/dist/core/runtime/nodes.d.ts +4 -0
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/promote.js +3 -0
- package/dist/core/runtime/reset.js +3 -2
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/broker-crash-teardown.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Broker crash / teardown / boot-failure — acceptance items 4–6 of the headless-
|
|
4
|
+
// broker migration plus the M-1/M-2 boot-failure regression. Split out of
|
|
5
|
+
// broker-lifecycle.test.ts (see its header for the full file map); the tests are
|
|
6
|
+
// the original acceptance gate, unchanged, on their own isolated harness:
|
|
7
|
+
// 4 survive a broker crash via grace-revive RESUME on the saved .jsonl → "CRASH"
|
|
8
|
+
// 5 tear down cleanly — close → shutdown frame → exit, socket unlinked → "clean teardown"
|
|
9
|
+
// 6 ONE-WRITER (R2) — never two engine pids alive across crash→grace-revive → "CRASH"
|
|
10
|
+
// M-1/M-2 a broker that dies before session_start is reaped, not stranded → "boot failure"
|
|
11
|
+
import { test, before, after } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
16
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
17
|
+
import { FAIL_BEFORE_SESSION_START } from './fixtures/fake-engine.js';
|
|
18
|
+
// crtrd.ts module const (not exported). The fresh-pi-boot grace window the daemon
|
|
19
|
+
// waits before grace-reviving a pi observed dead. Reference: crtrd.ts
|
|
20
|
+
// `REVIVE_GRACE_MS = 20_000`.
|
|
21
|
+
const REVIVE_GRACE_MS = 20_000;
|
|
22
|
+
let h;
|
|
23
|
+
let root;
|
|
24
|
+
before(async () => {
|
|
25
|
+
if (!hasTmux())
|
|
26
|
+
return;
|
|
27
|
+
h = await createHarness({ sessionPrefix: 'crtr-brkcrash' });
|
|
28
|
+
root = h.spawnRoot('broker-crash suite root');
|
|
29
|
+
});
|
|
30
|
+
after(async () => {
|
|
31
|
+
if (h !== undefined)
|
|
32
|
+
await h.dispose();
|
|
33
|
+
});
|
|
34
|
+
// ===========================================================================
|
|
35
|
+
// Item 4 + 6 — broker CRASH → grace-revive RESUME on the saved .jsonl; ONE-
|
|
36
|
+
// WRITER: the crashed pid is dead BEFORE the revive launches and never
|
|
37
|
+
// resurrects, and the revived pid is distinct (never two engine pids at once).
|
|
38
|
+
// ===========================================================================
|
|
39
|
+
test('CRASH → grace-revive RESUME; one-writer (old pid dead before new, never resurrects)', { skip: !hasTmux() }, async () => {
|
|
40
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — crash');
|
|
41
|
+
const oldPid = h.node(id).pi_pid;
|
|
42
|
+
assert.equal(isPidAlive(oldPid), true, 'broker alive before the crash');
|
|
43
|
+
assert.equal(h.node(id).intent ?? null, null, 'fresh broker has a null intent (not refresh/idle-release)');
|
|
44
|
+
const boots = h.bootCount(id);
|
|
45
|
+
// Kill the broker out from under the daemon (a crash). Use a fixed clock so the
|
|
46
|
+
// grace window is exercised deterministically.
|
|
47
|
+
process.kill(oldPid, 'SIGKILL');
|
|
48
|
+
await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
|
|
49
|
+
// ONE-WRITER: the old engine pid is dead BEFORE any revive can launch.
|
|
50
|
+
assert.equal(isPidAlive(oldPid), false, 'crashed pid dead before the daemon revives');
|
|
51
|
+
const NOW = 5_000_000;
|
|
52
|
+
await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
|
|
53
|
+
assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
|
|
54
|
+
await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
|
|
55
|
+
const boot2 = await h.awaitBoot(id, { minCount: boots + 1 });
|
|
56
|
+
assert.equal(boot2.resuming, true, 'grace-revive RESUMES the saved .jsonl');
|
|
57
|
+
const newPid = h.node(id).pi_pid;
|
|
58
|
+
assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
|
|
59
|
+
assert.notEqual(newPid, oldPid, 'the revived pid is distinct from the crashed one');
|
|
60
|
+
assert.equal(isPidAlive(oldPid), false, 'one-writer: the crashed pid never resurrected');
|
|
61
|
+
});
|
|
62
|
+
// ===========================================================================
|
|
63
|
+
// Item 5 — clean teardown: close → hostFor(meta).teardown → `shutdown` frame →
|
|
64
|
+
// broker disposes + exits, socket unlinked, status canceled, daemon leaves it.
|
|
65
|
+
// ===========================================================================
|
|
66
|
+
test('clean teardown — node close → broker shutdown frame → exit, socket unlinked, not revived', { skip: !hasTmux() }, async () => {
|
|
67
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — teardown');
|
|
68
|
+
const pid = h.node(id).pi_pid;
|
|
69
|
+
const sock = h.brokerSock(id);
|
|
70
|
+
await h.waitFor(() => existsSync(sock), { label: 'broker created its view.sock' });
|
|
71
|
+
// Close via the REAL CLI (resolves the node from --node; the subprocess carries
|
|
72
|
+
// no TMUX_PANE). closeNode marks canceled BEFORE teardown (crash-safe order),
|
|
73
|
+
// then hostFor(broker).teardown sends the `shutdown` frame.
|
|
74
|
+
const res = h.cli(root, ['node', 'close', '--node', id]);
|
|
75
|
+
assert.equal(res.code, 0, `node close should exit 0\n--stderr--\n${res.stderr}`);
|
|
76
|
+
await h.waitFor(() => !isPidAlive(pid), { label: 'broker process exited on shutdown frame' });
|
|
77
|
+
await h.waitFor(() => !existsSync(sock), { label: 'broker unlinked its socket on exit' });
|
|
78
|
+
assert.equal(h.status(id), 'canceled', 'the closed node is canceled');
|
|
79
|
+
// listNodes only surfaces active|idle, so a canceled broker is never supervised.
|
|
80
|
+
await h.tick();
|
|
81
|
+
assert.equal(h.status(id), 'canceled', 'still canceled after a daemon tick');
|
|
82
|
+
assert.equal(isPidAlive(pid), false, 'the broker stays dead — never revived');
|
|
83
|
+
});
|
|
84
|
+
// ===========================================================================
|
|
85
|
+
// M-1 regression — a broker that DIES before session_start must surface a boot
|
|
86
|
+
// failure, not strand the node forever. Before the fix handleBrokerLiveness read
|
|
87
|
+
// pid==null UNCONDITIONALLY as "still booting", so a broker that threw before
|
|
88
|
+
// recording a pid/session left the node 'active' with no engine FOREVER and the
|
|
89
|
+
// parent waited on a dead child. The daemon must instead crash it past the boot
|
|
90
|
+
// grace + push a boot-failure up the spine. (Also proves M-2: the broker's fatal
|
|
91
|
+
// diagnostic reaches job/broker.log instead of /dev/null.)
|
|
92
|
+
// ===========================================================================
|
|
93
|
+
test('boot failure — a broker that dies before session_start is reaped, not stranded (review M-1/M-2)', { skip: !hasTmux() }, async () => {
|
|
94
|
+
// A headless child whose broker throws before session_start (no pid, no session
|
|
95
|
+
// EVER recorded). spawnHeadlessChildNoBoot does not awaitBoot (there is none).
|
|
96
|
+
const id = await h.spawnHeadlessChildNoBoot(root, `headless worker — boot fail ${FAIL_BEFORE_SESSION_START}`);
|
|
97
|
+
// M-2: the broker logs its fatal stack to job/broker.log and exits(1). Without
|
|
98
|
+
// the stdio redirect this diagnostic would be lost to /dev/null.
|
|
99
|
+
const brokerLog = join(h.home, 'nodes', id, 'job', 'broker.log');
|
|
100
|
+
await h.waitFor(() => existsSync(brokerLog) && /simulated pre-session_start boot failure/.test(readFileSync(brokerLog, 'utf8')), { label: 'broker logged its pre-session_start failure to job/broker.log' });
|
|
101
|
+
// The node is stuck 'active' with a null pid and a null session — the strand.
|
|
102
|
+
const n0 = h.node(id);
|
|
103
|
+
assert.equal(n0.pi_pid ?? null, null, 'no broker pid was ever recorded');
|
|
104
|
+
assert.equal(n0.pi_session_id ?? null, null, 'no session was ever recorded');
|
|
105
|
+
// Inside the boot grace the daemon LEAVES it (could be the sub-second boot gap).
|
|
106
|
+
const NOW = 9_000_000;
|
|
107
|
+
await h.tick(NOW);
|
|
108
|
+
assert.equal(h.status(id), 'active', 'inside the boot grace → daemon leaves it (boot gap)');
|
|
109
|
+
// Past the boot grace with STILL no pid and no session → crash + boot failure.
|
|
110
|
+
await h.tick(NOW + REVIVE_GRACE_MS + 1);
|
|
111
|
+
await h.waitForStatus(id, 'dead');
|
|
112
|
+
assert.equal(h.status(id), 'dead', 'a never-booted broker is reaped, not stranded active forever');
|
|
113
|
+
// …and the parent (an active subscriber of the child) was told up the spine.
|
|
114
|
+
const note = h.inbox(root).find((e) => /never started/.test(e.label ?? ''));
|
|
115
|
+
assert.ok(note !== undefined, 'the parent received a boot-failure notice up the spine');
|
|
116
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/broker-dialogs.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Broker dialog handling — the §5.4 C2 forward-progress proof plus the T8 attach
|
|
4
|
+
// gates G5/G5b/G6. Split out of broker-lifecycle.test.ts (see its header for the
|
|
5
|
+
// full file map); the tests are the original acceptance gate, unchanged, on their
|
|
6
|
+
// own isolated harness. The attach-client helpers live in
|
|
7
|
+
// helpers/broker-clients.ts (the PRODUCTION ViewSocketClient, §0 one-writer: a
|
|
8
|
+
// viewer holds ONLY a socket).
|
|
9
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
12
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
13
|
+
import { createAttachKit } from './helpers/broker-clients.js';
|
|
14
|
+
let h;
|
|
15
|
+
let root;
|
|
16
|
+
const kit = createAttachKit(() => h);
|
|
17
|
+
const { attach, attachUntil } = kit;
|
|
18
|
+
before(async () => {
|
|
19
|
+
if (!hasTmux())
|
|
20
|
+
return;
|
|
21
|
+
h = await createHarness({ sessionPrefix: 'crtr-brkdlg' });
|
|
22
|
+
root = h.spawnRoot('broker-dialogs suite root');
|
|
23
|
+
});
|
|
24
|
+
after(async () => {
|
|
25
|
+
if (h !== undefined)
|
|
26
|
+
await h.dispose();
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
kit.closeAll();
|
|
30
|
+
});
|
|
31
|
+
const brokerPid = (id) => h.node(id).pi_pid;
|
|
32
|
+
// ===========================================================================
|
|
33
|
+
// C2 forward-progress (zero-viewer path) — an unattended blocking dialog
|
|
34
|
+
// resolves to its default (false) IMMEDIATELY: with no controller connected the
|
|
35
|
+
// broker's REAL makeBrokerUiContext falls back to noOp resolution, so the engine
|
|
36
|
+
// never deadlocks AND never waits on a per-dialog timeout (the design §5.4
|
|
37
|
+
// timeout premise is false). Red/green: the OLD broker armed a setTimeout and
|
|
38
|
+
// resolved only after `timeout` ms; the fixed broker resolves in ~0ms. (Supports
|
|
39
|
+
// acceptance item 7: the existing suite stays green; this only ADDS coverage.)
|
|
40
|
+
// ===========================================================================
|
|
41
|
+
test('C2 — unattended dialog resolves to its default IMMEDIATELY (noOp), never waits on a timeout', { skip: !hasTmux() }, async () => {
|
|
42
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — dialog');
|
|
43
|
+
const pid = h.node(id).pi_pid;
|
|
44
|
+
// Drive the fake engine to call uiContext.confirm(..., { timeout: 5000 }) with
|
|
45
|
+
// NO controller. C2 fix: makeBrokerUiContext resolves the default (false) AT
|
|
46
|
+
// ONCE — it does NOT arm/await the timeout. A generous < 2000ms bound is still a
|
|
47
|
+
// hard fail against the old ~5000ms timeout-wait while staying robust on slow CI.
|
|
48
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
|
|
49
|
+
const results = await h.waitFor(() => {
|
|
50
|
+
const r = h.dialogResults(id);
|
|
51
|
+
return r.length > 0 ? r : null;
|
|
52
|
+
}, { timeoutMs: 15_000, label: 'unattended dialog resolved' });
|
|
53
|
+
assert.equal(results[0].resolved, false, 'unattended confirm resolves to its default (false / deny)');
|
|
54
|
+
assert.ok(results[0].ms < 2000, `C2: resolved IMMEDIATELY (noOp), not after the 5000ms timeout — got ${results[0].ms}ms`);
|
|
55
|
+
assert.equal(isPidAlive(pid), true, 'the broker made forward progress (still alive, did not hang or exit)');
|
|
56
|
+
});
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// G5 — dialog forward + answer. Guards: a blocking dialog reaches the controller
|
|
59
|
+
// as extension_ui_request and the controller's extension_ui_response unblocks the
|
|
60
|
+
// engine with ITS answer (not the default). Failure mode: a dialog the controller
|
|
61
|
+
// can't see/answer (silent deadlock).
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
test('G5 — controller receives an extension_ui_request, answers it, and the engine proceeds with that answer', { skip: !hasTmux() }, async () => {
|
|
64
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G5a');
|
|
65
|
+
const c = await attach(id, 'controller', 'g5-ctrl');
|
|
66
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 20_000 }); // generous: the controller answers first
|
|
67
|
+
const req = await c.waitFrame((f) => f.type === 'extension_ui_request', 'G5 dialog forwarded to controller');
|
|
68
|
+
assert.equal(req.method, 'confirm', 'G5: the forwarded dialog is the confirm() the engine raised');
|
|
69
|
+
const reqId = req.id;
|
|
70
|
+
c.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
71
|
+
const results = await h.waitFor(() => {
|
|
72
|
+
const r = h.dialogResults(id);
|
|
73
|
+
return r.length > 0 ? r : null;
|
|
74
|
+
}, { label: 'G5 dialog resolved by the controller', timeoutMs: 15_000 });
|
|
75
|
+
assert.equal(results[0].resolved, true, 'G5: the engine proceeded with the controller answer (true), not the default (false)');
|
|
76
|
+
});
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// G5 (mid-dialog attach) — Guards: a dialog raised under a prior controller stays
|
|
79
|
+
// pending across that controller's detach (M2) and is delivered to whoever takes
|
|
80
|
+
// control next via welcome.pending_dialog. Failure mode: a pending dialog lost on
|
|
81
|
+
// controller handoff.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
test('G5 — a controller attaching MID-dialog receives the pending dialog via welcome.pending_dialog', { skip: !hasTmux() }, async () => {
|
|
84
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G5b');
|
|
85
|
+
const a = await attach(id, 'controller', 'g5b-A');
|
|
86
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 30_000 }); // stays pending long enough for the handoff
|
|
87
|
+
const reqA = await a.waitFrame((f) => f.type === 'extension_ui_request', 'G5b dialog forwarded to controller A');
|
|
88
|
+
const reqId = reqA.id;
|
|
89
|
+
a.send({ type: 'bye' });
|
|
90
|
+
a.close(); // M2: detach frees control but does NOT cancel the pending dialog
|
|
91
|
+
// Controller B takes control (retry covers the close→controllerId=null beat) and
|
|
92
|
+
// its welcome carries the still-pending dialog.
|
|
93
|
+
const b = await attachUntil(id, 'controller', 'g5b-B', (x) => x.welcome.role === 'controller' && x.welcome.pending_dialog != null, 'G5b controller B takes control with the pending dialog');
|
|
94
|
+
assert.equal(b.welcome.pending_dialog.id, reqId, 'G5b: welcome.pending_dialog is the same dialog raised under A');
|
|
95
|
+
assert.equal(b.welcome.pending_dialog.method, 'confirm', 'G5b: the pending dialog is the confirm()');
|
|
96
|
+
b.send({ type: 'extension_ui_response', id: reqId, confirmed: true });
|
|
97
|
+
const results = await h.waitFor(() => {
|
|
98
|
+
const r = h.dialogResults(id);
|
|
99
|
+
return r.length > 0 ? r : null;
|
|
100
|
+
}, { label: 'G5b dialog resolved by controller B', timeoutMs: 15_000 });
|
|
101
|
+
assert.equal(results[0].resolved, true, 'G5b: controller B answered the handed-off dialog and the engine proceeded');
|
|
102
|
+
});
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// G6 — anti-deadlock. (a) a zero-viewer dialog resolves to its default AT ONCE
|
|
105
|
+
// (noOp). (b) an ATTENDED dialog the controller never answers resolves on a SHORT
|
|
106
|
+
// per-dialog broker timeout. Guards: the engine never hangs on a dialog with no
|
|
107
|
+
// answerer. Failure mode: a forever-blocked turn.
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
test('G6 — zero-viewer dialog resolves immediately; an unanswered attended dialog resolves on the broker timeout', { skip: !hasTmux() }, async () => {
|
|
110
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — G6');
|
|
111
|
+
const pid = brokerPid(id);
|
|
112
|
+
// (a) zero viewers → immediate noOp default (NOT the 5000ms timeout).
|
|
113
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 5000 });
|
|
114
|
+
const r1 = await h.waitFor(() => (h.dialogResults(id).length >= 1 ? h.dialogResults(id) : null), { label: 'G6a zero-viewer dialog resolved', timeoutMs: 15_000 });
|
|
115
|
+
assert.equal(r1[0].resolved, false, 'G6a: zero-viewer dialog resolves to the default (deny)');
|
|
116
|
+
assert.ok(r1[0].ms < 2000, `G6a: resolved immediately (noOp), not after the 5000ms timeout — got ${r1[0].ms}ms`);
|
|
117
|
+
// (b) controller attached but silent → the broker resolves on the SHORT explicit
|
|
118
|
+
// per-dialog timeout (800ms), never the 120s default.
|
|
119
|
+
const c = await attach(id, 'controller', 'g6-ctrl');
|
|
120
|
+
h.fakeCmd(id, { cmd: 'dialog', timeout: 800 });
|
|
121
|
+
await c.waitFrame((f) => f.type === 'extension_ui_request', 'G6b dialog forwarded to controller'); // received, deliberately NOT answered
|
|
122
|
+
const r2 = await h.waitFor(() => (h.dialogResults(id).length >= 2 ? h.dialogResults(id) : null), { label: 'G6b attended dialog resolved on timeout', timeoutMs: 15_000 });
|
|
123
|
+
assert.equal(r2[1].resolved, false, 'G6b: an unanswered attended dialog resolves to the default (deny)');
|
|
124
|
+
assert.ok(r2[1].ms >= 600 && r2[1].ms < 5000, `G6b: resolved on the ~800ms per-dialog timeout, not instantly and not the 120s default — got ${r2[1].ms}ms`);
|
|
125
|
+
assert.equal(isPidAlive(pid), true, 'G6: the engine made forward progress on both dialogs (still alive)');
|
|
126
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/broker-dormant-wake.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Acceptance item 3 (DORMANT) of the headless-broker migration. Split out of
|
|
4
|
+
// broker-lifecycle.test.ts (see its header for the full file map); the test is
|
|
5
|
+
// the original acceptance gate, unchanged, on its own isolated harness.
|
|
6
|
+
import { test, before, after } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
9
|
+
import { subscribe } from '../canvas/canvas.js';
|
|
10
|
+
import { appendInbox } from '../feed/inbox.js';
|
|
11
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
12
|
+
let h;
|
|
13
|
+
let root;
|
|
14
|
+
before(async () => {
|
|
15
|
+
if (!hasTmux())
|
|
16
|
+
return;
|
|
17
|
+
h = await createHarness({ sessionPrefix: 'crtr-brkdorm' });
|
|
18
|
+
// An active resident root: the spawn parent AND the live node a child can hold
|
|
19
|
+
// an active subscription to (so its natural stop classifies 'awaiting').
|
|
20
|
+
root = h.spawnRoot('broker-dormant suite root');
|
|
21
|
+
});
|
|
22
|
+
after(async () => {
|
|
23
|
+
if (h !== undefined)
|
|
24
|
+
await h.dispose();
|
|
25
|
+
});
|
|
26
|
+
// ===========================================================================
|
|
27
|
+
// Item 3 (DORMANT) — a natural stop while awaiting a live subscription idle-
|
|
28
|
+
// RELEASES the broker (it exits); the daemon's second pass revives it (RESUME)
|
|
29
|
+
// on the next unseen inbox entry.
|
|
30
|
+
// ===========================================================================
|
|
31
|
+
test('DORMANT wake — idle-release → broker exits → daemon pass-2 reviveNode(resume) on inbox', { skip: !hasTmux() }, async () => {
|
|
32
|
+
const id = await h.spawnHeadlessChild(root, 'headless worker — dormant wake');
|
|
33
|
+
const pid = h.node(id).pi_pid;
|
|
34
|
+
// Give the (terminal) child an ACTIVE subscription to the LIVE root, so the
|
|
35
|
+
// stop-guard classifies its natural stop as 'awaiting' (a dormant orchestrator
|
|
36
|
+
// awaiting a worker) → the stophook idle-releases it (paneless → no focus).
|
|
37
|
+
subscribe(id, root, true);
|
|
38
|
+
await h.stop(id, 'stop');
|
|
39
|
+
await h.waitForStatus(id, 'idle');
|
|
40
|
+
assert.equal(h.node(id).intent, 'idle-release', 'awaiting + unfocused → idle-release');
|
|
41
|
+
await h.waitFor(() => !isPidAlive(pid), { label: 'released broker process exited' });
|
|
42
|
+
// Daemon owns wake-on-message for a dormant (pi-dead) broker: an unseen inbox
|
|
43
|
+
// entry + a tick → pass 2 → reviveNode(resume:true) → a fresh broker boots.
|
|
44
|
+
appendInbox(id, { from: 'tester', tier: 'normal', kind: 'message', label: 'resume-me', data: { body: 'work after release' } });
|
|
45
|
+
await h.tick();
|
|
46
|
+
const boot2 = await h.awaitBoot(id, { minCount: 2 });
|
|
47
|
+
assert.equal(boot2.resuming, true, 'the dormant revive RESUMES the saved session');
|
|
48
|
+
const newPid = h.node(id).pi_pid;
|
|
49
|
+
assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
|
|
50
|
+
assert.notEqual(newPid, pid, 'the revived broker is a fresh process');
|
|
51
|
+
});
|