@north-light/crouter 0.3.235 → 0.3.237
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/api/client.d.ts +2 -1
- package/dist/api/client.js +3 -0
- package/dist/api/dto/broker-ops.d.ts +1 -1
- package/dist/api/dto/common.d.ts +2 -0
- package/dist/api/dto/lifecycle.d.ts +9 -2
- package/dist/api/dto/messages.d.ts +5 -0
- package/dist/api/dto/nodes.d.ts +14 -5
- package/dist/api/dto/worktree.d.ts +10 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base/00-authoring.md +1 -1
- package/dist/builtin-memory/internal/agent-shaping.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/sysprompt-window.ts +1 -1
- package/dist/clients/attach/render/tool-calls.js +18 -3
- package/dist/clients/attach/viewer.js +399 -399
- package/dist/clients/inbox/review/__tests__/editor-roundtrip.test.js +6 -0
- package/dist/clients/inbox/review/roundtrip.js +1 -1
- package/dist/commands/__tests__/node-new.test.js +58 -0
- package/dist/commands/memory/lint.js +22 -13
- package/dist/commands/memory/shared.js +1 -1
- package/dist/commands/node/create.d.ts +1 -1
- package/dist/commands/node/create.js +4 -1
- package/dist/commands/node/lifecycle.js +2 -2
- package/dist/commands/node-worktree.js +39 -8
- package/dist/commands/revive.js +11 -2
- package/dist/commands/sys/context/admin/actions.d.ts +48 -0
- package/dist/commands/sys/context/admin/actions.js +246 -0
- package/dist/commands/sys/context/admin/axes-panel.d.ts +21 -0
- package/dist/commands/sys/context/admin/axes-panel.js +117 -0
- package/dist/commands/sys/context/admin/detail-panel.d.ts +53 -0
- package/dist/commands/sys/context/admin/detail-panel.js +178 -0
- package/dist/commands/sys/context/admin/docs-panel.d.ts +45 -0
- package/dist/commands/sys/context/admin/docs-panel.js +186 -0
- package/dist/commands/sys/context/admin/model.d.ts +75 -0
- package/dist/commands/sys/context/admin/model.js +209 -0
- package/dist/commands/sys/context/admin/shell.d.ts +82 -0
- package/dist/commands/sys/context/admin/shell.js +669 -0
- package/dist/commands/sys/context/admin.d.ts +1 -0
- package/dist/commands/sys/context/admin.js +124 -0
- package/dist/commands/sys/context/doc.d.ts +1 -0
- package/dist/commands/sys/context/doc.js +196 -0
- package/dist/commands/sys/context/prompt-review.d.ts +1 -0
- package/dist/commands/sys/{prompt-review.js → context/prompt-review.js} +10 -10
- package/dist/commands/sys/context/resolve.d.ts +55 -0
- package/dist/commands/sys/context/resolve.js +428 -0
- package/dist/commands/sys/context/sysprompt.d.ts +1 -0
- package/dist/commands/sys/{sysprompt.js → context/sysprompt.js} +11 -11
- package/dist/commands/sys/context.d.ts +2 -0
- package/dist/commands/sys/context.js +20 -0
- package/dist/commands/sys.js +3 -4
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
- package/dist/core/__tests__/child-death-wake.test.js +38 -22
- package/dist/core/__tests__/cron-self-cancel-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/cron-self-cancel-delivery.test.js +72 -0
- package/dist/core/__tests__/daemon-boot.test.js +40 -28
- package/dist/core/__tests__/helpers/harness.js +1 -0
- package/dist/core/__tests__/human-deliver.test.js +1 -1
- package/dist/core/__tests__/human-node-not-supervised.test.js +10 -15
- package/dist/core/__tests__/integration/revive.test.js +12 -12
- package/dist/core/__tests__/integration/spawn-root.test.js +6 -0
- package/dist/core/__tests__/integration/worktree-reap.test.js +32 -0
- package/dist/core/__tests__/lifecycle-terminal-reasons.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +61 -0
- package/dist/core/__tests__/migration.test.js +38 -1
- package/dist/core/__tests__/push-final-guard.test.js +1 -1
- package/dist/core/__tests__/revive-capacity.test.d.ts +1 -0
- package/dist/core/__tests__/revive-capacity.test.js +127 -0
- package/dist/core/__tests__/revive-parked-fresh.test.js +7 -7
- package/dist/core/__tests__/seam/broker-cap-freeze.test.d.ts +1 -0
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +81 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +76 -7
- package/dist/core/__tests__/seam/yield-refresh-transaction.test.js +1 -1
- package/dist/core/canvas/browse/render.js +11 -3
- package/dist/core/canvas/canvas.d.ts +15 -3
- package/dist/core/canvas/canvas.js +52 -16
- package/dist/core/canvas/migrations.js +21 -0
- package/dist/core/canvas/nav-render.js +3 -1
- package/dist/core/canvas/remote-canvas-source.js +2 -0
- package/dist/core/canvas/render-source.d.ts +3 -1
- package/dist/core/canvas/render-source.js +7 -3
- package/dist/core/canvas/status-glyph.d.ts +10 -1
- package/dist/core/canvas/status-glyph.js +19 -3
- package/dist/core/canvas/types.d.ts +22 -3
- package/dist/core/feed/feed.js +1 -1
- package/dist/core/human/feedback-companion.js +5 -2
- package/dist/core/memory/lint.d.ts +19 -0
- package/dist/core/memory/lint.js +97 -0
- package/dist/core/memory-resolver.d.ts +4 -0
- package/dist/core/memory-resolver.js +7 -7
- package/dist/core/preview-registry.js +4 -4
- package/dist/core/review/realize.js +4 -2
- package/dist/core/runtime/close.js +1 -1
- package/dist/core/runtime/fleet.d.ts +29 -1
- package/dist/core/runtime/fleet.js +23 -0
- package/dist/core/runtime/host.js +32 -11
- package/dist/core/runtime/launch-prompt.d.ts +1 -1
- package/dist/core/runtime/launch-prompt.js +2 -2
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/lifecycle.d.ts +4 -2
- package/dist/core/runtime/lifecycle.js +12 -1
- package/dist/core/runtime/memory.js +1 -1
- package/dist/core/runtime/reopen.js +10 -1
- package/dist/core/runtime/reset.js +1 -1
- package/dist/core/runtime/revive-all.d.ts +3 -0
- package/dist/core/runtime/revive-all.js +10 -4
- package/dist/core/runtime/revive.d.ts +12 -0
- package/dist/core/runtime/revive.js +62 -3
- package/dist/core/runtime/spawn.d.ts +9 -13
- package/dist/core/runtime/spawn.js +40 -17
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/core/substrate/index.d.ts +6 -3
- package/dist/core/substrate/index.js +4 -3
- package/dist/core/substrate/on-read.d.ts +0 -8
- package/dist/core/substrate/on-read.js +45 -124
- package/dist/core/substrate/plan.d.ts +94 -0
- package/dist/core/substrate/plan.js +266 -0
- package/dist/core/substrate/render.d.ts +5 -6
- package/dist/core/substrate/render.js +76 -129
- package/dist/core/substrate/schema.d.ts +1 -6
- package/dist/core/substrate/schema.js +1 -15
- package/dist/core/substrate/session-cache.d.ts +2 -10
- package/dist/core/substrate/session-cache.js +9 -47
- package/dist/core/substrate/subject-fields.d.ts +1 -4
- package/dist/core/substrate/subject-fields.js +2 -5
- package/dist/core/substrate/surface-match.d.ts +37 -17
- package/dist/core/substrate/surface-match.js +44 -53
- package/dist/core/tui/page-host.d.ts +6 -0
- package/dist/core/tui/page-host.js +12 -3
- package/dist/core/worktree.d.ts +8 -0
- package/dist/core/worktree.js +65 -0
- package/dist/daemon/__tests__/reconciler-signature.test.js +3 -2
- package/dist/daemon/__tests__/thaw-order.test.d.ts +1 -0
- package/dist/daemon/__tests__/thaw-order.test.js +55 -0
- package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +6 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +6 -0
- package/dist/daemon/api/__tests__/profile-launch-gates.test.js +1 -1
- package/dist/daemon/api/bridge.js +5 -1
- package/dist/daemon/api/handlers/attach.js +5 -1
- package/dist/daemon/api/handlers/bash-jobs.js +1 -1
- package/dist/daemon/api/handlers/messages.js +10 -2
- package/dist/daemon/api/handlers/nodes.js +8 -4
- package/dist/daemon/api/handlers/worktree.js +10 -1
- package/dist/daemon/api/map.js +7 -0
- package/dist/daemon/companion-retire.js +1 -1
- package/dist/daemon/cron/passes.d.ts +0 -5
- package/dist/daemon/cron/passes.js +0 -20
- package/dist/daemon/cron/sinks.js +1 -1
- package/dist/daemon/cron-run.d.ts +5 -6
- package/dist/daemon/cron-run.js +7 -16
- package/dist/daemon/crtrd.d.ts +0 -10
- package/dist/daemon/crtrd.js +23 -61
- package/dist/daemon/fleet.d.ts +10 -7
- package/dist/daemon/fleet.js +71 -12
- package/dist/daemon/messaging/node-message.js +3 -1
- package/dist/daemon/profile-delete.js +1 -1
- package/dist/daemon/reconcilers/broker-supervision.d.ts +26 -9
- package/dist/daemon/reconcilers/broker-supervision.js +39 -52
- package/dist/daemon/reconcilers/controller-death.d.ts +7 -0
- package/dist/daemon/reconcilers/controller-death.js +26 -0
- package/dist/daemon/reconcilers/cron-lane.d.ts +0 -3
- package/dist/daemon/reconcilers/cron-lane.js +0 -3
- package/dist/daemon/reconcilers/live-obligation.js +3 -1
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +53 -0
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +170 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.d.ts +5 -0
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +16 -0
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +4 -0
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +13 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +26 -0
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +140 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/commands/sys/prompt-review.d.ts +0 -1
- package/dist/commands/sys/sysprompt.d.ts +0 -1
- package/dist/core/__tests__/cron-broker-capacity.test.js +0 -119
- package/dist/daemon/reconcilers/dormant-inbox.d.ts +0 -18
- package/dist/daemon/reconcilers/dormant-inbox.js +0 -157
- /package/dist/{core/__tests__/cron-broker-capacity.test.d.ts → commands/__tests__/node-new.test.d.ts} +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Tests for the naming/goal-capture gap on the durable-inbox delivery path.
|
|
2
|
+
//
|
|
3
|
+
// Run with: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/canvas-inbox-watcher-naming.test.ts
|
|
4
|
+
//
|
|
5
|
+
// A cold-booting child's first message falls to the durable inbox and pi's
|
|
6
|
+
// `input` event never fires for it, so canvas-goal-capture's first-message
|
|
7
|
+
// hooks (naming, goal capture) never run for it. The inbox watcher now mirrors
|
|
8
|
+
// both on its own `kind: 'human'` visible delivery. This pins that mirror:
|
|
9
|
+
// a human delivery reaches the naming pipeline and captures the goal, a
|
|
10
|
+
// card/envelope delivery touches neither, and an already-described node's
|
|
11
|
+
// naming call still fetches state (the daemon owns the compare-and-set) but
|
|
12
|
+
// never issues a commit.
|
|
13
|
+
//
|
|
14
|
+
// The naming attempt is observed at the daemon socket exactly as in
|
|
15
|
+
// canvas-goal-capture-envelope.test.ts: generateAndCommitInitialName starts by
|
|
16
|
+
// asking crtrd for the node's projection (GET .../broker/extension-state), so
|
|
17
|
+
// a request for a node id is proof the pipeline ran for it. An unnamed node's
|
|
18
|
+
// GET is answered with an error — same as that file — so the test never
|
|
19
|
+
// reaches the real headless model call; a "described" node's GET succeeds with
|
|
20
|
+
// a non-empty description, which is enough to prove the guard short-circuits
|
|
21
|
+
// before any commit (POST .../broker/generated-name) is sent.
|
|
22
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
25
|
+
import { createServer } from 'node:net';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
|
|
29
|
+
import { createNode } from '../canvas/canvas.js';
|
|
30
|
+
import { closeDb } from '../canvas/db.js';
|
|
31
|
+
import { apiSocketPath } from '../canvas/paths.js';
|
|
32
|
+
import { appendInbox } from '../feed/inbox.js';
|
|
33
|
+
const TICK_MS = 20;
|
|
34
|
+
const DEBOUNCE_MS = 25;
|
|
35
|
+
let home;
|
|
36
|
+
let socket;
|
|
37
|
+
const getRequests = [];
|
|
38
|
+
const postRequests = [];
|
|
39
|
+
/** node_id -> description the stub answers `extension-state` with. Absent =
|
|
40
|
+
* the GET errors (mirrors canvas-goal-capture-envelope.test.ts: proof of
|
|
41
|
+
* attempt without a real model call). Present (even '') = the GET succeeds. */
|
|
42
|
+
const descriptions = new Map();
|
|
43
|
+
function node(id) {
|
|
44
|
+
return {
|
|
45
|
+
node_id: id,
|
|
46
|
+
name: id,
|
|
47
|
+
created: new Date().toISOString(),
|
|
48
|
+
cwd: '/tmp/work',
|
|
49
|
+
kind: 'general',
|
|
50
|
+
mode: 'base',
|
|
51
|
+
lifecycle: 'terminal',
|
|
52
|
+
status: 'active',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function initialPromptPath(id) {
|
|
56
|
+
return join(home, 'nodes', id, 'context', 'initial-prompt.md');
|
|
57
|
+
}
|
|
58
|
+
function respond(connection, status, body) {
|
|
59
|
+
const json = JSON.stringify(body);
|
|
60
|
+
connection.end(`HTTP/1.1 ${status}\r\n`
|
|
61
|
+
+ 'Content-Type: application/json\r\n'
|
|
62
|
+
+ `Content-Length: ${Buffer.byteLength(json)}\r\n`
|
|
63
|
+
+ 'Connection: close\r\n\r\n'
|
|
64
|
+
+ json);
|
|
65
|
+
}
|
|
66
|
+
function nodeIdFromPath(path) {
|
|
67
|
+
return path.split('/nodes/')[1]?.split('/')[0] ?? '';
|
|
68
|
+
}
|
|
69
|
+
/** Every node's watcher fetches `extension-state` ONCE at session_start (to
|
|
70
|
+
* seed report-node names for card rendering) regardless of what it later
|
|
71
|
+
* delivers, so a bare presence check can't tell a startup fetch from a
|
|
72
|
+
* naming-triggered one. Naming issues a SECOND, independent fetch for the
|
|
73
|
+
* same node id — count occurrences instead of checking presence. */
|
|
74
|
+
function getRequestCount(nodeId) {
|
|
75
|
+
return getRequests.filter((line) => line.includes(`/nodes/${nodeId}/`)).length;
|
|
76
|
+
}
|
|
77
|
+
function makeFakePi() {
|
|
78
|
+
const handlers = {};
|
|
79
|
+
return {
|
|
80
|
+
injected: [],
|
|
81
|
+
sentMessages: [],
|
|
82
|
+
on(e, h) { handlers[e] = h; },
|
|
83
|
+
sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
|
|
84
|
+
sendMessage(message) { this.sentMessages.push(message); },
|
|
85
|
+
fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
89
|
+
async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
|
|
90
|
+
const deadline = Date.now() + timeoutMs;
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
if (predicate())
|
|
93
|
+
return;
|
|
94
|
+
await wait(stepMs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const disposers = [];
|
|
98
|
+
function registerWatcher(pi) {
|
|
99
|
+
const dispose = registerCanvasInboxWatcher(pi);
|
|
100
|
+
pi.fire('session_start', { type: 'session_start' }, undefined);
|
|
101
|
+
return dispose;
|
|
102
|
+
}
|
|
103
|
+
/** Point CRTR_HOME/CRTR_NODE_ID at this node and make sure it exists in canvas.db. */
|
|
104
|
+
function freshNode(id) {
|
|
105
|
+
process.env['CRTR_NODE_ID'] = id;
|
|
106
|
+
createNode(node(id));
|
|
107
|
+
}
|
|
108
|
+
let origHome;
|
|
109
|
+
let origNode;
|
|
110
|
+
let origTick;
|
|
111
|
+
let origDebounce;
|
|
112
|
+
before(async () => {
|
|
113
|
+
origHome = process.env['CRTR_HOME'];
|
|
114
|
+
origNode = process.env['CRTR_NODE_ID'];
|
|
115
|
+
origTick = process.env['CRTR_WATCHER_TICK_MS'];
|
|
116
|
+
origDebounce = process.env['CRTR_WATCHER_DEBOUNCE_MS'];
|
|
117
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-inbox-naming-'));
|
|
118
|
+
mkdirSync(join(home, 'nodes'), { recursive: true });
|
|
119
|
+
process.env['CRTR_HOME'] = home;
|
|
120
|
+
process.env['CRTR_WATCHER_TICK_MS'] = String(TICK_MS);
|
|
121
|
+
process.env['CRTR_WATCHER_DEBOUNCE_MS'] = String(DEBOUNCE_MS);
|
|
122
|
+
socket = createServer((connection) => {
|
|
123
|
+
connection.on('data', (chunk) => {
|
|
124
|
+
const raw = chunk.toString('utf8');
|
|
125
|
+
const requestLine = raw.split('\r\n')[0] ?? '';
|
|
126
|
+
const [method, path] = requestLine.split(' ');
|
|
127
|
+
if (method === 'GET' && path?.includes('/broker/extension-state')) {
|
|
128
|
+
getRequests.push(raw);
|
|
129
|
+
const nodeId = nodeIdFromPath(path);
|
|
130
|
+
if (!descriptions.has(nodeId)) {
|
|
131
|
+
respond(connection, '500 Internal Server Error', { error: { code: 'internal', message: 'stub daemon' } });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
respond(connection, '200 OK', {
|
|
135
|
+
node: {
|
|
136
|
+
node_id: nodeId, name: nodeId, description: descriptions.get(nodeId), kind: 'general',
|
|
137
|
+
mode: 'base', lifecycle: 'terminal', status: 'active', cwd: '/tmp/work',
|
|
138
|
+
parent: null, fork_from: null, profile_id: null, intent: null, created: new Date().toISOString(),
|
|
139
|
+
},
|
|
140
|
+
warm_spare: false, ancestors: [], children: [], fork_source: null,
|
|
141
|
+
subject: { kind: 'general', mode: 'base', lifecycle: 'terminal', hasManager: false, cwd: '/tmp/work', scope: 'project', orchestration: { depth: 0 }, profile: null },
|
|
142
|
+
report_nodes: [],
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (method === 'POST' && path?.includes('/broker/generated-name')) {
|
|
147
|
+
postRequests.push(raw);
|
|
148
|
+
respond(connection, '200 OK', { applied: false });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Everything else (e.g. advanceBrokerInboxCursor) — a harmless error is
|
|
152
|
+
// fine: the watcher retries cursor commits later and never blocks
|
|
153
|
+
// visible delivery on them.
|
|
154
|
+
respond(connection, '500 Internal Server Error', { error: { code: 'internal', message: 'stub daemon' } });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
await new Promise((resolve) => socket.listen(apiSocketPath(), resolve));
|
|
158
|
+
});
|
|
159
|
+
afterEach(() => {
|
|
160
|
+
while (disposers.length > 0)
|
|
161
|
+
disposers.pop()();
|
|
162
|
+
});
|
|
163
|
+
after(async () => {
|
|
164
|
+
closeDb();
|
|
165
|
+
await new Promise((resolve) => socket.close(() => resolve()));
|
|
166
|
+
rmSync(home, { recursive: true, force: true });
|
|
167
|
+
if (origHome === undefined)
|
|
168
|
+
delete process.env['CRTR_HOME'];
|
|
169
|
+
else
|
|
170
|
+
process.env['CRTR_HOME'] = origHome;
|
|
171
|
+
if (origNode === undefined)
|
|
172
|
+
delete process.env['CRTR_NODE_ID'];
|
|
173
|
+
else
|
|
174
|
+
process.env['CRTR_NODE_ID'] = origNode;
|
|
175
|
+
if (origTick === undefined)
|
|
176
|
+
delete process.env['CRTR_WATCHER_TICK_MS'];
|
|
177
|
+
else
|
|
178
|
+
process.env['CRTR_WATCHER_TICK_MS'] = origTick;
|
|
179
|
+
if (origDebounce === undefined)
|
|
180
|
+
delete process.env['CRTR_WATCHER_DEBOUNCE_MS'];
|
|
181
|
+
else
|
|
182
|
+
process.env['CRTR_WATCHER_DEBOUNCE_MS'] = origDebounce;
|
|
183
|
+
});
|
|
184
|
+
test('an inbox-delivered human first message reaches the naming pipeline and captures the goal', async () => {
|
|
185
|
+
freshNode('inbox-human-unnamed');
|
|
186
|
+
const pi = makeFakePi();
|
|
187
|
+
disposers.push(registerWatcher(pi));
|
|
188
|
+
await wait(TICK_MS + 100);
|
|
189
|
+
appendInbox('inbox-human-unnamed', {
|
|
190
|
+
from: 'human',
|
|
191
|
+
tier: 'normal',
|
|
192
|
+
kind: 'message',
|
|
193
|
+
label: 'build me a receipt tracker',
|
|
194
|
+
data: { body: 'build me a receipt tracker' },
|
|
195
|
+
});
|
|
196
|
+
await waitFor(() => pi.injected.length >= 1);
|
|
197
|
+
assert.equal(pi.injected[0].content, 'build me a receipt tracker', 'the human body still rides the visible digest verbatim');
|
|
198
|
+
// One GET at session_start (report-node seed) plus one from the naming
|
|
199
|
+
// pipeline (generateAndCommitInitialName's own state fetch) = 2.
|
|
200
|
+
await waitFor(() => getRequestCount('inbox-human-unnamed') >= 2);
|
|
201
|
+
assert.equal(getRequestCount('inbox-human-unnamed'), 2, 'an inbox-delivered human message reaches the same naming pipeline a live message does');
|
|
202
|
+
assert.equal(readFileSync(initialPromptPath('inbox-human-unnamed'), 'utf8').trim(), 'build me a receipt tracker', 'an inbox-delivered first message is also captured as the goal, for fresh-revive recovery');
|
|
203
|
+
});
|
|
204
|
+
test('a card/envelope delivery never reaches naming or goal capture', async () => {
|
|
205
|
+
freshNode('inbox-card-only');
|
|
206
|
+
const pi = makeFakePi();
|
|
207
|
+
const dispose = registerWatcher(pi);
|
|
208
|
+
await wait(TICK_MS + 100);
|
|
209
|
+
appendInbox('inbox-card-only', { from: 'child-9', tier: 'normal', kind: 'update', label: 'phase one done' });
|
|
210
|
+
await waitFor(() => pi.injected.length >= 1, 3000);
|
|
211
|
+
assert.equal(pi.injected.length, 1, 'the card still reaches the digest as a visible send');
|
|
212
|
+
dispose(); // release ownership of this node id before a second watcher binds a new one
|
|
213
|
+
// Clock: a human message to a DIFFERENT node whose naming pipeline we know
|
|
214
|
+
// fires (proven above). Once its GET lands, the card node's window to fire
|
|
215
|
+
// its own GET has already passed — the watcher fires naming synchronously
|
|
216
|
+
// off the same delivery cycle, not on a later timer.
|
|
217
|
+
freshNode('inbox-card-clock');
|
|
218
|
+
const clockPi = makeFakePi();
|
|
219
|
+
disposers.push(registerWatcher(clockPi));
|
|
220
|
+
await wait(TICK_MS + 100);
|
|
221
|
+
appendInbox('inbox-card-clock', { from: 'human', tier: 'normal', kind: 'message', label: 'clock', data: { body: 'clock' } });
|
|
222
|
+
await waitFor(() => getRequests.some((line) => line.includes('inbox-card-clock')));
|
|
223
|
+
// Exactly the one startup GET (report-node seed) — no second, naming-triggered fetch.
|
|
224
|
+
assert.equal(getRequestCount('inbox-card-only'), 1, 'a card/envelope delivery never asks the naming pipeline to name the node');
|
|
225
|
+
assert.equal(existsSync(initialPromptPath('inbox-card-only')), false, 'a card/envelope delivery never seeds a goal file either');
|
|
226
|
+
});
|
|
227
|
+
test('an already-described node fetches state but never commits a new name', async () => {
|
|
228
|
+
descriptions.set('inbox-already-named', 'existing-description');
|
|
229
|
+
freshNode('inbox-already-named');
|
|
230
|
+
const pi = makeFakePi();
|
|
231
|
+
const dispose = registerWatcher(pi);
|
|
232
|
+
await wait(TICK_MS + 100);
|
|
233
|
+
appendInbox('inbox-already-named', {
|
|
234
|
+
from: 'human',
|
|
235
|
+
tier: 'normal',
|
|
236
|
+
kind: 'message',
|
|
237
|
+
label: 'second thing to do',
|
|
238
|
+
data: { body: 'second thing to do' },
|
|
239
|
+
});
|
|
240
|
+
await waitFor(() => pi.injected.length >= 1);
|
|
241
|
+
// One GET at session_start plus one from the naming pipeline's own state
|
|
242
|
+
// fetch: the guard is checked AFTER this fetch resolves, not before it.
|
|
243
|
+
await waitFor(() => getRequestCount('inbox-already-named') >= 2);
|
|
244
|
+
assert.equal(getRequestCount('inbox-already-named'), 2, 'the naming pipeline still asks the daemon for the current projection');
|
|
245
|
+
dispose(); // release ownership of this node id before a second watcher binds a new one
|
|
246
|
+
// Clock: a second node's naming pipeline fires synchronously off the same
|
|
247
|
+
// delivery cycle (proven in the first test above). Once its GET has landed,
|
|
248
|
+
// this node's guard — checked in the very same .then() before any commit
|
|
249
|
+
// call — has already resolved one way or the other.
|
|
250
|
+
freshNode('inbox-commit-clock');
|
|
251
|
+
const clockPi = makeFakePi();
|
|
252
|
+
disposers.push(registerWatcher(clockPi));
|
|
253
|
+
await wait(TICK_MS + 100);
|
|
254
|
+
appendInbox('inbox-commit-clock', { from: 'human', tier: 'normal', kind: 'message', label: 'clock', data: { body: 'clock' } });
|
|
255
|
+
await waitFor(() => getRequests.some((line) => line.includes('inbox-commit-clock')));
|
|
256
|
+
await wait(DEBOUNCE_MS + 100);
|
|
257
|
+
assert.equal(postRequests.some((line) => line.includes('inbox-already-named')), false, 'an already-described node is untouched: the guard short-circuits before any generated-name commit');
|
|
258
|
+
});
|
|
@@ -38,7 +38,9 @@ import { closeNode } from '../runtime/close.js';
|
|
|
38
38
|
import { superviseTick } from '../../daemon/crtrd.js';
|
|
39
39
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
40
40
|
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
41
|
+
import { headlessBrokerHost } from '../runtime/host.js';
|
|
41
42
|
let home;
|
|
43
|
+
let tf;
|
|
42
44
|
function node(id, over = {}) {
|
|
43
45
|
return {
|
|
44
46
|
node_id: id,
|
|
@@ -96,9 +98,9 @@ const wakeFromChild = (parent, child) => readInboxSince(parent).find((e) => e.fr
|
|
|
96
98
|
before(() => {
|
|
97
99
|
home = mkdtempSync(join(tmpdir(), 'crtr-child-death-wake-'));
|
|
98
100
|
process.env['CRTR_HOME'] = home;
|
|
99
|
-
// superviseTick requires a bound fleet (D-2).
|
|
100
|
-
//
|
|
101
|
-
bindTestFleet();
|
|
101
|
+
// superviseTick requires a bound fleet (D-2). It starts empty: none of these
|
|
102
|
+
// fabricated rows has a live broker.
|
|
103
|
+
tf = bindTestFleet();
|
|
102
104
|
});
|
|
103
105
|
beforeEach(() => {
|
|
104
106
|
closeDb();
|
|
@@ -111,27 +113,41 @@ after(() => {
|
|
|
111
113
|
resetFleetForTests();
|
|
112
114
|
});
|
|
113
115
|
// BROKER CUT — exit-event authority REPLACES daemon pane-gone reaping. A booted
|
|
114
|
-
// child whose engine pid is dead (and whose viewer pane is gone) is
|
|
116
|
+
// child whose engine pid is dead (and whose viewer pane is gone) is RECOVERED,
|
|
115
117
|
// not reaped, and the daemon's tick fans NO wake to the dormant parent. This
|
|
116
118
|
// single case subsumes the four deleted scenarios (crash / quiet-finalize /
|
|
117
|
-
// awaiting a live grandchild / pending self-wake): with pane state ignored
|
|
118
|
-
// surfaceChildDeath gone,
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
test('exit-event authority: a dead-engine booted child is
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
119
|
+
// awaiting a live grandchild / pending self-wake): with pane state ignored and
|
|
120
|
+
// surfaceChildDeath gone, the daemon treats them all identically — no reap, no
|
|
121
|
+
// liveness wake — so the old CRUX (no spurious wake on healthy dormancy) holds
|
|
122
|
+
// by construction. The node-lifecycle tick relaunches the row through the same
|
|
123
|
+
// policy table its exit event would have used (D-12); that recovery is silent.
|
|
124
|
+
test('exit-event authority: a dead-engine booted child is recovered, NOT reaped, and the tick fans NO wake to its dormant parent', async () => {
|
|
125
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
126
|
+
const launches = [];
|
|
127
|
+
headlessBrokerHost.launch = (nodeId) => {
|
|
128
|
+
launches.push(nodeId);
|
|
129
|
+
const handle = { pid: 424_242, exited: new Promise(() => { }) };
|
|
130
|
+
tf.fleet.register(nodeId, handle); // the real host contract: registration at the launch seam
|
|
131
|
+
return handle;
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
inboxWaitingParent('PARENT');
|
|
135
|
+
deadEngineChild('CHILD', { cwd: home }); // a cwd that exists — reviveNode's preflight refuses a missing one
|
|
136
|
+
subscribe('PARENT', 'CHILD', true); // the spawn-time spine edge
|
|
137
|
+
await superviseTick();
|
|
138
|
+
// A stale recorded pid is metadata, not a verdict: the row is relaunched on
|
|
139
|
+
// its saved session, never reaped to dead/done/idle.
|
|
140
|
+
assert.deepEqual(launches, ['CHILD'], 'the tick recovers the dead engine through the policy table');
|
|
141
|
+
assert.equal(getNode('CHILD').status, 'active', 'CHILD is recovered, NOT reaped');
|
|
142
|
+
// surfaceChildDeath is deleted: the daemon raises no liveness wake. The
|
|
143
|
+
// doctrine wake now rides the child's own `push final` (or close.ts), so a
|
|
144
|
+
// purely-inbox-waiting parent is never spuriously woken on dormancy/crash.
|
|
145
|
+
assert.equal(readInboxSince('PARENT').length, 0, 'PARENT inbox EMPTY — no daemon liveness wake');
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
149
|
+
tf.fleet.forget('CHILD');
|
|
150
|
+
}
|
|
135
151
|
});
|
|
136
152
|
test('node close of a child wakes its SURVIVING manager (the parent outside the closing set)', async () => {
|
|
137
153
|
inboxWaitingParent('PARENT');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/cron-self-cancel-delivery.test.ts
|
|
2
|
+
//
|
|
3
|
+
// A cron whose own command cancels its row mid-run must still deliver the
|
|
4
|
+
// output that run produced: cancellation retires the schedule, not the run in
|
|
5
|
+
// flight.
|
|
6
|
+
import { after, afterEach, before, test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
12
|
+
import { closeDb } from '../canvas/db.js';
|
|
13
|
+
import { armCron, cancelCron, getCron } from '../canvas/crons.js';
|
|
14
|
+
import { executeCron, markSelfCanceledCronRun } from '../../daemon/cron-run.js';
|
|
15
|
+
let home;
|
|
16
|
+
let realHome;
|
|
17
|
+
function arm(overrides) {
|
|
18
|
+
armCron({
|
|
19
|
+
name: overrides.cron_id,
|
|
20
|
+
created_by: null,
|
|
21
|
+
fire_at: new Date(Date.now() - 1_000).toISOString(),
|
|
22
|
+
recur: null,
|
|
23
|
+
tz: null,
|
|
24
|
+
expires_at: null,
|
|
25
|
+
anchor_node: null,
|
|
26
|
+
cancel_on_wake: false,
|
|
27
|
+
cwd: home,
|
|
28
|
+
env_json: null,
|
|
29
|
+
profile: null,
|
|
30
|
+
scope: 'profile',
|
|
31
|
+
run_timeout_s: 30,
|
|
32
|
+
overlap: 'skip',
|
|
33
|
+
on_output: 'silent',
|
|
34
|
+
sink: '{}',
|
|
35
|
+
tier: 'normal',
|
|
36
|
+
...overrides,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
before(() => {
|
|
40
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-cron-broker-capacity-'));
|
|
41
|
+
realHome = process.env['CRTR_HOME'];
|
|
42
|
+
process.env['CRTR_HOME'] = home;
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
closeDb();
|
|
46
|
+
});
|
|
47
|
+
after(() => {
|
|
48
|
+
closeDb();
|
|
49
|
+
rmSync(home, { recursive: true, force: true });
|
|
50
|
+
if (realHome === undefined)
|
|
51
|
+
delete process.env['CRTR_HOME'];
|
|
52
|
+
else
|
|
53
|
+
process.env['CRTR_HOME'] = realHome;
|
|
54
|
+
});
|
|
55
|
+
test('an on-change cron that cancels itself still delivers its final output', async () => {
|
|
56
|
+
const runIdPath = join(home, 'self-cancel-run-id');
|
|
57
|
+
arm({
|
|
58
|
+
cron_id: 'self-cancel-delivery',
|
|
59
|
+
command: `printf '%s' "$CRTR_RUN_ID" > '${runIdPath}'; echo settled; sleep 0.1`,
|
|
60
|
+
recur: JSON.stringify({ every: '1h' }),
|
|
61
|
+
on_output: 'on-change',
|
|
62
|
+
sink: JSON.stringify({ human: true }),
|
|
63
|
+
});
|
|
64
|
+
const run = executeCron(getCron('self-cancel-delivery'), { outOfBand: false });
|
|
65
|
+
for (let i = 0; i < 50 && !existsSync(runIdPath); i++)
|
|
66
|
+
await delay(10);
|
|
67
|
+
assert.ok(existsSync(runIdPath), 'the live run exposed its id to its self-cancel command');
|
|
68
|
+
assert.equal(markSelfCanceledCronRun('self-cancel-delivery', readFileSync(runIdPath, 'utf8')), true);
|
|
69
|
+
assert.equal(cancelCron('self-cancel-delivery', { preserveRunningProcess: true }), true);
|
|
70
|
+
const record = await run;
|
|
71
|
+
assert.equal(record?.delivered, 'delivered: human send --inbox');
|
|
72
|
+
});
|
|
@@ -15,6 +15,7 @@ import { verifyDaemonStartup, waitForDaemonExit } from '../../daemon/manage.js';
|
|
|
15
15
|
import { waitForBrokerViewSocket } from '../runtime/placement-tmux.js';
|
|
16
16
|
import { viewSocketPath } from '../canvas/paths.js';
|
|
17
17
|
import { markBusy, clearBusy } from '../runtime/busy.js';
|
|
18
|
+
import { headlessBrokerHost } from '../runtime/host.js';
|
|
18
19
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
19
20
|
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
20
21
|
let home;
|
|
@@ -92,34 +93,45 @@ test('never-booted broker exit is marked dead and surfaced to the parent as urge
|
|
|
92
93
|
assert.ok(wake, 'the doctrine wake rides beside the urgent push');
|
|
93
94
|
assert.match(wake.label, /crashed/i);
|
|
94
95
|
});
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
96
|
+
// surfaceChildDeath was DELETED, and the node-lifecycle tick recovers a crashed
|
|
97
|
+
// booted child through the SAME policy table its exit event would have used
|
|
98
|
+
// (design D-12), so tick recovery and exit recovery can never diverge. A row
|
|
99
|
+
// whose recorded pid is provably dead and whose session was recorded is a
|
|
100
|
+
// resumable crash: it is relaunched on that saved session, never reaped to
|
|
101
|
+
// `dead` and never announced as a boot failure or a child death.
|
|
102
|
+
test('a crashed booted child is relaunched on its saved session, not reaped, and raises no false boot-failure alarm', async () => {
|
|
103
|
+
const originalLaunch = headlessBrokerHost.launch;
|
|
104
|
+
const launches = [];
|
|
105
|
+
headlessBrokerHost.launch = (nodeId) => {
|
|
106
|
+
launches.push(nodeId);
|
|
107
|
+
const handle = { pid: 424_242, exited: new Promise(() => { }) };
|
|
108
|
+
tf.fleet.register(nodeId, handle); // the real host contract: registration at the launch seam
|
|
109
|
+
return handle;
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
|
|
113
|
+
createNode(node('C2', {
|
|
114
|
+
cwd: home, // a cwd that exists — reviveNode's preflight refuses a missing one
|
|
115
|
+
parent: 'P2',
|
|
116
|
+
kind: 'explore',
|
|
117
|
+
pi_pid: deadPid(), // engine crashed — its recorded pid is dead
|
|
118
|
+
pi_session_id: '019e8f00-booted-once', // it booted before dying
|
|
119
|
+
intent: null,
|
|
120
|
+
}));
|
|
121
|
+
spawnEdge('P2', 'C2');
|
|
122
|
+
markBusy('C2'); // died mid-generation
|
|
123
|
+
await superviseTick();
|
|
124
|
+
assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
|
|
125
|
+
assert.equal(getNode('C2').status, 'active', 'crashed booted child is relaunched, NOT marked dead');
|
|
126
|
+
// surfaceChildDeath is gone and this is not a never-booted node, so the daemon
|
|
127
|
+
// fans NO push here — no false boot-failure / child-death alarm on a crash.
|
|
128
|
+
assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
|
|
129
|
+
clearBusy('C2');
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
133
|
+
tf.fleet.forget('C2');
|
|
134
|
+
}
|
|
123
135
|
});
|
|
124
136
|
// An unplaced active node is not daemon-managed; the daemon leaves it untouched
|
|
125
137
|
// rather than treating the missing broker/session fields as a boot failure.
|
|
@@ -505,6 +505,7 @@ export async function createHarness(opts = {}) {
|
|
|
505
505
|
prompt: task,
|
|
506
506
|
parent: parentId,
|
|
507
507
|
...(o.mode !== undefined ? { mode: o.mode } : {}),
|
|
508
|
+
...(o.lifecycle !== undefined ? { rootLifecycle: o.lifecycle } : {}),
|
|
508
509
|
...(o.model !== undefined ? { model: o.model } : {}),
|
|
509
510
|
});
|
|
510
511
|
await harness.awaitBoot(result.node.node_id);
|
|
@@ -192,7 +192,7 @@ describe('terminal ticket delivery', () => {
|
|
|
192
192
|
await deliverTerminalResult(bridge);
|
|
193
193
|
assert.equal(getNode(companion)?.final_report, finalReport, 'terminal replay preserves the first canonical final');
|
|
194
194
|
assert.equal(getNode(companion)?.finalized_at, finalizedAt, 'terminal replay preserves the first finalization time');
|
|
195
|
-
assert.throws(() => reviveNode(companion, { resume: true }), /finalization latch/);
|
|
195
|
+
assert.throws(() => reviveNode(companion, { resume: true, capacity: 'freeze' }), /finalization latch/);
|
|
196
196
|
const { bridge: dismissedBridge } = bridgeUnder();
|
|
197
197
|
const dismissedDir = submitBridgePage(dismissedBridge);
|
|
198
198
|
const dismissedCompanion = spawnNode({ kind: 'general', parent: dismissedBridge, cwd: home, name: 'dismissed feedback companion', lifecycle: 'resident' }).node_id;
|
|
@@ -14,12 +14,11 @@
|
|
|
14
14
|
// (pid==null, session==null) as a never-booted broker and boot-failed it —
|
|
15
15
|
// killing the human prompt before the person could ever answer.
|
|
16
16
|
//
|
|
17
|
-
// THE FIX under exit-event authority
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// of the exact bug.
|
|
17
|
+
// THE FIX under exit-event authority is superviseTick's one filter, driven
|
|
18
|
+
// here: it drops kind:'human' rows before every row pass, including the
|
|
19
|
+
// node-lifecycle tick that classifies a row with no live broker as known-dead.
|
|
20
|
+
// An unfiltered human row (session null, no fork claim) would land in policy
|
|
21
|
+
// row 8 (boot failure), the modern re-incarnation of the exact bug.
|
|
23
22
|
import { test, before, after, beforeEach } from 'node:test';
|
|
24
23
|
import assert from 'node:assert/strict';
|
|
25
24
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
@@ -28,7 +27,7 @@ import { join } from 'node:path';
|
|
|
28
27
|
import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
29
28
|
import { closeDb } from '../canvas/db.js';
|
|
30
29
|
import { readInboxSince } from '../feed/inbox.js';
|
|
31
|
-
import { superviseTick
|
|
30
|
+
import { superviseTick } from '../../daemon/crtrd.js';
|
|
32
31
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
33
32
|
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
34
33
|
let home;
|
|
@@ -49,7 +48,7 @@ function node(id, over = {}) {
|
|
|
49
48
|
before(() => {
|
|
50
49
|
home = mkdtempSync(join(tmpdir(), 'crtr-human-not-supervised-'));
|
|
51
50
|
process.env['CRTR_HOME'] = home;
|
|
52
|
-
tf = bindTestFleet(); // superviseTick
|
|
51
|
+
tf = bindTestFleet(); // superviseTick requires a bound fleet (D-2)
|
|
53
52
|
});
|
|
54
53
|
beforeEach(() => {
|
|
55
54
|
closeDb();
|
|
@@ -79,16 +78,12 @@ test('a kind:"human" bridge row is NEVER boot-failed by the daemon (it has no br
|
|
|
79
78
|
// the tick leave IT alone — it is only here to catch a push.
|
|
80
79
|
createNode(node('ASKER', { status: 'idle', intent: 'idle-release', pi_session_id: 'booted' }));
|
|
81
80
|
subscribe('ASKER', 'HUMAN', true);
|
|
82
|
-
// Two well-spaced ticks:
|
|
83
|
-
//
|
|
84
|
-
//
|
|
81
|
+
// Two well-spaced ticks: the row passes (controller-wait settle, inbox wake,
|
|
82
|
+
// and the node-lifecycle classification of a row with no live broker) must
|
|
83
|
+
// all drop the human row before reading it.
|
|
85
84
|
const t0 = Date.now();
|
|
86
85
|
await superviseTick(t0);
|
|
87
86
|
await superviseTick(t0 + 10_000_000);
|
|
88
|
-
// The startup recovery sweep is the sharper threat: it classifies every swept
|
|
89
|
-
// active|idle row as known-dead, so only its kind:'human' filter keeps the
|
|
90
|
-
// bridge row out of policy row 8 (boot failure).
|
|
91
|
-
await runStartupRecoverySweep(tf.fleet);
|
|
92
87
|
assert.equal(getNode('HUMAN').status, 'active', 'human bridge row stays alive — never crash-reaped as a dead broker');
|
|
93
88
|
assert.equal(readInboxSince('ASKER').length, 0, 'no surfaceBootFailure urgent push — the asker is never told its human prompt "never started"');
|
|
94
89
|
});
|