@north-light/crouter 0.3.240 → 0.3.242
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/dto/broker-ops.d.ts +8 -3
- package/dist/api/dto/broker.d.ts +4 -2
- package/dist/api/dto/inbox.d.ts +1 -0
- package/dist/api/dto/nodes.d.ts +4 -0
- package/dist/api/dto/review-comments.d.ts +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/clients/attach/session/profile-files.js +4 -1
- package/dist/clients/attach/viewer.js +366 -366
- package/dist/commands/node/create.js +8 -3
- package/dist/commands/node/inspect.js +1 -1
- package/dist/commands/sys/context/admin/detail-panel.d.ts +3 -0
- package/dist/commands/sys/context/admin/detail-panel.js +32 -3
- package/dist/commands/sys/context/admin/docs-panel.d.ts +23 -13
- package/dist/commands/sys/context/admin/docs-panel.js +101 -103
- package/dist/commands/sys/context/admin/list-view.d.ts +53 -0
- package/dist/commands/sys/context/admin/list-view.js +125 -0
- package/dist/commands/sys/context/admin/model.d.ts +11 -0
- package/dist/commands/sys/context/admin/model.js +28 -1
- package/dist/commands/sys/context/admin/rail-panel.d.ts +46 -0
- package/dist/commands/sys/context/admin/{axes-panel.js → rail-panel.js} +64 -21
- package/dist/commands/sys/context/admin/shell.d.ts +19 -2
- package/dist/commands/sys/context/admin/shell.js +142 -46
- package/dist/commands/sys/daemon.js +1 -1
- package/dist/core/__tests__/child-death-wake.test.js +0 -48
- package/dist/core/__tests__/daemon-boot.test.js +0 -8
- package/dist/core/__tests__/dead-node-policy-table.test.js +1 -2
- package/dist/core/__tests__/integration/deferred-no-wake.test.js +14 -2
- package/dist/core/__tests__/lifecycle.test.js +5 -5
- package/dist/core/__tests__/relaunch-root.test.js +91 -1
- package/dist/core/__tests__/revive-capacity.test.js +36 -1
- package/dist/core/__tests__/seam/broker-cap-freeze.test.js +73 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +1 -2
- package/dist/core/canvas/browse/model.d.ts +0 -9
- package/dist/core/canvas/browse/model.js +1 -37
- package/dist/core/canvas/browse/render.js +2 -1
- package/dist/core/canvas/canvas.d.ts +6 -5
- package/dist/core/canvas/canvas.js +8 -7
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/review/realize.js +1 -1
- package/dist/core/runtime/broker/node-named.d.ts +1 -0
- package/dist/core/runtime/host.js +114 -103
- package/dist/core/runtime/lifecycle.js +3 -2
- package/dist/core/runtime/naming.d.ts +26 -8
- package/dist/core/runtime/naming.js +75 -43
- package/dist/core/runtime/reset.js +12 -5
- package/dist/core/runtime/revive-all.d.ts +4 -11
- package/dist/core/runtime/revive-all.js +6 -13
- package/dist/core/runtime/tmux-bindings.js +4 -3
- package/dist/core/runtime/warm-pool.js +4 -4
- package/dist/core/tui/fuzzy.d.ts +9 -0
- package/dist/core/tui/fuzzy.js +38 -0
- package/dist/daemon/api/__tests__/node-create-description.test.js +1 -0
- package/dist/daemon/api/handlers/broker-ops.js +14 -7
- package/dist/daemon/api/handlers/feedback-comments.js +1 -1
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/crtrd.js +17 -20
- package/dist/daemon/messaging/node-message.d.ts +1 -0
- package/dist/daemon/messaging/node-message.js +6 -2
- package/dist/daemon/reconcilers/live-obligation.d.ts +1 -2
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +0 -5
- package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +9 -13
- package/dist/daemon/reconcilers/node-lifecycle/respawn-policy.js +0 -6
- package/dist/daemon/reconcilers/node-lifecycle/terminating.d.ts +0 -2
- package/dist/daemon/reconcilers/node-lifecycle/terminating.js +0 -10
- package/dist/daemon/reconcilers/node-lifecycle/tick.d.ts +2 -2
- package/dist/daemon/reconcilers/node-lifecycle/tick.js +4 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +2 -2
- package/dist/daemon/review/comment-notify.d.ts +1 -0
- package/dist/daemon/review/comment-notify.js +1 -0
- package/dist/pi-extensions/broker-local.d.ts +1 -0
- package/dist/pi-extensions/broker-local.js +12 -7
- package/dist/pi-extensions/canvas-recap.js +1 -0
- package/dist/pi-extensions/canvas-stophook.js +2 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/commands/sys/context/admin/axes-panel.d.ts +0 -21
|
@@ -93,12 +93,6 @@ test('never-booted broker exit is marked dead and surfaced to the parent as urge
|
|
|
93
93
|
assert.ok(wake, 'the doctrine wake rides beside the urgent push');
|
|
94
94
|
assert.match(wake.label, /crashed/i);
|
|
95
95
|
});
|
|
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
96
|
test('a crashed booted child is relaunched on its saved session, not reaped, and raises no false boot-failure alarm', async () => {
|
|
103
97
|
const originalLaunch = headlessBrokerHost.launch;
|
|
104
98
|
const launches = [];
|
|
@@ -123,8 +117,6 @@ test('a crashed booted child is relaunched on its saved session, not reaped, and
|
|
|
123
117
|
await superviseTick();
|
|
124
118
|
assert.deepEqual(launches, ['C2'], 'the tick recovers the crash through the policy table, exactly as the exit event would');
|
|
125
119
|
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
120
|
assert.equal(readInboxSince('P2').length, 0, 'no false boot-failure / child-death push on a crash (wake relocated to push/close)');
|
|
129
121
|
clearBusy('C2');
|
|
130
122
|
}
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The invariant-5 policy table (design flow B): `classifyDeadNode` is the ONE
|
|
4
4
|
// pure function mapping a known-dead node's durable row state to its recovery
|
|
5
|
-
// action, used
|
|
6
|
-
// recovery sweep — so exit recovery and startup recovery can never diverge.
|
|
5
|
+
// action, used by the exit-policy job and the row-driven supervision tick.
|
|
7
6
|
// This file pins every table row directly.
|
|
8
7
|
import { test } from 'node:test';
|
|
9
8
|
import assert from 'node:assert/strict';
|
|
@@ -44,7 +44,7 @@ import { tmpdir } from 'node:os';
|
|
|
44
44
|
import { join } from 'node:path';
|
|
45
45
|
import { spawnSync } from 'node:child_process';
|
|
46
46
|
import { DatabaseSync } from 'node:sqlite';
|
|
47
|
-
import { createNode, deleteNode, getNode, setStatus, withFreshTerminalGuard } from '../../canvas/canvas.js';
|
|
47
|
+
import { createNode, deleteNode, getNode, setFrozen, setStatus, withFreshTerminalGuard } from '../../canvas/canvas.js';
|
|
48
48
|
import { hasNoNaturalCycle } from '../../runtime/revive-all.js';
|
|
49
49
|
import { canvasDbPath, nodeDir, apiSocketPath } from '../../canvas/paths.js';
|
|
50
50
|
import { closeDb } from '../../canvas/db.js';
|
|
@@ -170,7 +170,7 @@ test('node message send --tier deferred to an ordinary dormant (idle-released) t
|
|
|
170
170
|
assert.equal(after.status, 'idle', 'target left exactly where it was');
|
|
171
171
|
assert.equal(after.intent, 'idle-release', 'target left exactly where it was');
|
|
172
172
|
});
|
|
173
|
-
test('node message send --tier deferred
|
|
173
|
+
test('node message send --tier deferred rejects ordinary terminal targets but accepts a frozen terminal target with a thaw cycle owed', { timeout: 30_000 }, async () => {
|
|
174
174
|
for (const status of ['done', 'canceled']) {
|
|
175
175
|
const id = `terminal-${status}`;
|
|
176
176
|
createNode(node(id, { status, intent: null }));
|
|
@@ -190,6 +190,18 @@ test('node message send --tier deferred to a terminal target (done/canceled —
|
|
|
190
190
|
assert.equal(launchCalls, 0, `${status}: a rejected deferred must never attempt a revive`);
|
|
191
191
|
assert.equal(readInboxSince(id, undefined).length, inboxBefore, `${status}: a rejected deferred must not append an inbox entry`);
|
|
192
192
|
}
|
|
193
|
+
const frozenId = 'terminal-frozen';
|
|
194
|
+
createNode(node(frozenId, {
|
|
195
|
+
lifecycle: 'resident',
|
|
196
|
+
status: 'done',
|
|
197
|
+
intent: 'parked',
|
|
198
|
+
}));
|
|
199
|
+
setFrozen(frozenId, new Date().toISOString());
|
|
200
|
+
const inboxBefore = readInboxSince(frozenId, undefined).length;
|
|
201
|
+
const result = await messageSendLeaf().run({ to: frozenId, tier: 'deferred', body: 'held for thaw' });
|
|
202
|
+
assert.equal(result['guidance'], "Delivered — held for the target's next natural cycle; not revived.", 'a frozen terminal row still has a thaw cycle');
|
|
203
|
+
assert.equal(result['revived'], false, 'deferred mail waits for the thaw without launching');
|
|
204
|
+
assert.equal(readInboxSince(frozenId, undefined).length, inboxBefore + 1, 'the deferred message is durably appended');
|
|
193
205
|
});
|
|
194
206
|
// #343 third-fix-cycle review: the IMMEDIATE `--tier deferred` path had its
|
|
195
207
|
// own, separate TOCTOU — a stale-snapshot check (`rejectDeferredWithoutNaturalCycle`)
|
|
@@ -133,17 +133,17 @@ test('yield: illegal from done|dead|canceled → throws', () => {
|
|
|
133
133
|
assert.throws(() => transition(`n_${from}`, 'yield'), /illegal lifecycle transition/);
|
|
134
134
|
}
|
|
135
135
|
});
|
|
136
|
-
// release → idle + intent='idle-release', legal
|
|
137
|
-
test('release: active|idle → idle + intent=idle-release', () => {
|
|
138
|
-
for (const from of LIVE) {
|
|
136
|
+
// release → idle + intent='idle-release', legal from active|idle|done
|
|
137
|
+
test('release: active|idle|done → idle + intent=idle-release', () => {
|
|
138
|
+
for (const from of [...LIVE, 'done']) {
|
|
139
139
|
mk(`n_${from}`, from);
|
|
140
140
|
const m = transition(`n_${from}`, 'release');
|
|
141
141
|
assert.equal(m.status, 'idle', `release from ${from}`);
|
|
142
142
|
assert.equal(m.intent, 'idle-release', `release from ${from} sets intent=idle-release`);
|
|
143
143
|
}
|
|
144
144
|
});
|
|
145
|
-
test('release: illegal from
|
|
146
|
-
for (const from of
|
|
145
|
+
test('release: illegal from dead|canceled → throws', () => {
|
|
146
|
+
for (const from of ['dead', 'canceled']) {
|
|
147
147
|
mk(`n_${from}`, from);
|
|
148
148
|
assert.throws(() => transition(`n_${from}`, 'release'), /illegal lifecycle transition/);
|
|
149
149
|
}
|
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
// behavior via the injectable deps seam, so they run tmux-free and broker-free.
|
|
9
9
|
import { test, before, after, beforeEach } from 'node:test';
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { CrtrClient } from '../../api/client.js';
|
|
15
|
+
import { createApiServer } from '../../daemon/api/server.js';
|
|
16
|
+
import registerCanvasStophook from '../../pi-extensions/canvas-stophook.js';
|
|
14
17
|
import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
15
18
|
import { closeDb } from '../canvas/db.js';
|
|
19
|
+
import { apiSocketPath } from '../canvas/paths.js';
|
|
20
|
+
import { brokerCapReached, resetFleetForTests } from '../runtime/fleet.js';
|
|
16
21
|
import { relaunchRoot } from '../runtime/reset.js';
|
|
22
|
+
import { bindTestFleet } from './helpers/fleet.js';
|
|
17
23
|
let home;
|
|
18
24
|
function node(id, over = {}) {
|
|
19
25
|
return {
|
|
@@ -31,6 +37,20 @@ function node(id, over = {}) {
|
|
|
31
37
|
/** A tmux-free / broker-free deps seam. `pid` controls the new broker launch:
|
|
32
38
|
* a number = success, null = a boot failure. Records the node ids launchBroker
|
|
33
39
|
* was called with, so the boot-failure test can find the half-born new node. */
|
|
40
|
+
async function waitForApi() {
|
|
41
|
+
const client = CrtrClient.forLocalSocket({ autostart: false });
|
|
42
|
+
for (let i = 0; i < 200; i++) {
|
|
43
|
+
if (existsSync(apiSocketPath())) {
|
|
44
|
+
try {
|
|
45
|
+
await client.healthz();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
catch { /* the socket can appear before it accepts */ }
|
|
49
|
+
}
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
51
|
+
}
|
|
52
|
+
throw new Error('API socket did not become ready');
|
|
53
|
+
}
|
|
34
54
|
function makeDeps(pid) {
|
|
35
55
|
const launched = [];
|
|
36
56
|
const deps = {
|
|
@@ -102,6 +122,76 @@ test('relaunchRoot on a boot failure leaves the old root + descendants fully int
|
|
|
102
122
|
assert.equal(launched.length, 1, 'one broker launch was attempted');
|
|
103
123
|
assert.equal(getNode(launched[0])?.status, 'dead', 'the half-born new node is crashed (dead)');
|
|
104
124
|
});
|
|
125
|
+
test('relaunchRoot at capacity refuses visibly and settles the half-born row before preserving the old root', async () => {
|
|
126
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
127
|
+
createNode(node('child', { parent: 'root' }));
|
|
128
|
+
subscribe('root', 'child', true);
|
|
129
|
+
const launched = [];
|
|
130
|
+
const deps = {
|
|
131
|
+
launchBroker: (nodeId) => {
|
|
132
|
+
launched.push(nodeId);
|
|
133
|
+
throw brokerCapReached(2, 2);
|
|
134
|
+
},
|
|
135
|
+
waitForViewSocket: () => true,
|
|
136
|
+
respawnViewer: () => true,
|
|
137
|
+
teardownBroker: () => { },
|
|
138
|
+
};
|
|
139
|
+
await assert.rejects(() => relaunchRoot('root', deps), (error) => {
|
|
140
|
+
const details = error.details ?? {};
|
|
141
|
+
assert.equal(details['error'], 'broker_cap_reached');
|
|
142
|
+
assert.match(String(details['message']), /2 live brokers at the cap of 2/);
|
|
143
|
+
return true;
|
|
144
|
+
});
|
|
145
|
+
assert.equal(getNode('root')?.status, 'active', 'the old root remains live');
|
|
146
|
+
assert.equal(getNode('child')?.status, 'active', 'its descendants remain live');
|
|
147
|
+
assert.equal(launched.length, 1);
|
|
148
|
+
assert.equal(getNode(launched[0])?.status, 'dead', 'the half-born replacement is terminal, not an active pid-less strand');
|
|
149
|
+
});
|
|
150
|
+
test('root /new capacity refusal reaches the attached human through broker UI notify', async () => {
|
|
151
|
+
process.env['CRTR_MAX_LIVE_BROKERS'] = '2';
|
|
152
|
+
process.env['CRTR_NODE_ID'] = 'root';
|
|
153
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
154
|
+
const tf = bindTestFleet();
|
|
155
|
+
const never = new Promise(() => { });
|
|
156
|
+
tf.fleet.register('filler-a', { pid: 700_001, exited: never });
|
|
157
|
+
tf.fleet.register('filler-b', { pid: 700_002, exited: never });
|
|
158
|
+
const server = createApiServer();
|
|
159
|
+
try {
|
|
160
|
+
await waitForApi();
|
|
161
|
+
let sessionStart;
|
|
162
|
+
registerCanvasStophook({
|
|
163
|
+
on(event, handler) {
|
|
164
|
+
if (event === 'session_start')
|
|
165
|
+
sessionStart = async (ev, ctx) => { await handler(ev, ctx); };
|
|
166
|
+
},
|
|
167
|
+
sendUserMessage() { },
|
|
168
|
+
sendMessage() { },
|
|
169
|
+
});
|
|
170
|
+
let resolveNotice;
|
|
171
|
+
const notice = new Promise((resolve) => { resolveNotice = resolve; });
|
|
172
|
+
await sessionStart?.({ reason: 'new' }, {
|
|
173
|
+
sessionManager: {
|
|
174
|
+
getSessionId: () => 'root-new-session',
|
|
175
|
+
getSessionFile: () => null,
|
|
176
|
+
getEntries: () => [],
|
|
177
|
+
},
|
|
178
|
+
ui: { notify: (message) => resolveNotice(message) },
|
|
179
|
+
});
|
|
180
|
+
const message = await Promise.race([
|
|
181
|
+
notice,
|
|
182
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('capacity notice was not shown')), 1_000)),
|
|
183
|
+
]);
|
|
184
|
+
assert.match(message, /^\/new refused: no broker slot is free/);
|
|
185
|
+
assert.match(message, /2 live brokers at the cap of 2/);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await server.close();
|
|
189
|
+
tf.fleet.dispose();
|
|
190
|
+
resetFleetForTests();
|
|
191
|
+
delete process.env['CRTR_MAX_LIVE_BROKERS'];
|
|
192
|
+
delete process.env['CRTR_NODE_ID'];
|
|
193
|
+
}
|
|
194
|
+
});
|
|
105
195
|
test('relaunchRoot is a no-op for a non-root child and an already-parked root', async () => {
|
|
106
196
|
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
107
197
|
createNode(node('child', { parent: 'root' }));
|
|
@@ -10,15 +10,18 @@
|
|
|
10
10
|
// finally happens retires the mark.
|
|
11
11
|
import { test, before, beforeEach, after } from 'node:test';
|
|
12
12
|
import assert from 'node:assert/strict';
|
|
13
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
|
14
14
|
import { tmpdir } from 'node:os';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { createNode, getNode } from '../canvas/canvas.js';
|
|
17
|
+
import { jobDir } from '../canvas/paths.js';
|
|
17
18
|
import { closeDb } from '../canvas/db.js';
|
|
18
19
|
import { bindDaemonEventSource, resetEventSourceForTests } from '../events/source.js';
|
|
19
20
|
import { resetFleetForTests } from '../runtime/fleet.js';
|
|
21
|
+
import { CANVAS_EXTENSIONS } from '../runtime/canvas-extensions.js';
|
|
20
22
|
import { headlessBrokerHost } from '../runtime/host.js';
|
|
21
23
|
import { reviveNode } from '../runtime/revive.js';
|
|
24
|
+
import { deliverNodeMessage } from '../../daemon/messaging/node-message.js';
|
|
22
25
|
import { bindTestFleet } from './helpers/fleet.js';
|
|
23
26
|
let home;
|
|
24
27
|
let tf;
|
|
@@ -89,6 +92,24 @@ test('a refusing caller gets an error naming the cap and the live count, never a
|
|
|
89
92
|
assert.equal(meta.pi_session_id, 'sess-refused', 'the refusal happens before any mutation of the session identity');
|
|
90
93
|
assert.equal(meta.cycles ?? 0, 0, 'a node turned away never spent a cycle');
|
|
91
94
|
});
|
|
95
|
+
test('a pre-spawn launch failure releases the reservation', () => {
|
|
96
|
+
const id = node('pre-spawn-failure');
|
|
97
|
+
mkdirSync(join(jobDir(id), 'broker.log'));
|
|
98
|
+
headlessBrokerHost.launch = originalLaunch;
|
|
99
|
+
try {
|
|
100
|
+
assert.throws(() => headlessBrokerHost.launch(id, {
|
|
101
|
+
argv: CANVAS_EXTENSIONS.flatMap((path) => ['-e', path]),
|
|
102
|
+
env: {},
|
|
103
|
+
}, { cwd: home, name: id, resuming: false }), /EISDIR/);
|
|
104
|
+
assert.equal(tf.fleet.occupancy(), 0, 'the failed launch leaves no slot reserved');
|
|
105
|
+
assert.equal(tf.fleet.reserveSlot(id), true, 'the same node can claim the released slot immediately');
|
|
106
|
+
tf.fleet.releaseReservation(id);
|
|
107
|
+
assert.equal(tf.fleet.occupancy(), 0);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
stubLaunch();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
92
113
|
test('a freezing caller gets a frozen row, marked once and left otherwise untouched', () => {
|
|
93
114
|
saturate();
|
|
94
115
|
const waiting = node('frozen', { pi_session_id: 'sess-frozen' });
|
|
@@ -102,6 +123,20 @@ test('a freezing caller gets a frozen row, marked once and left otherwise untouc
|
|
|
102
123
|
assert.equal(second.outcome, 'frozen');
|
|
103
124
|
assert.equal(getNode(waiting).frozen_at, marked.frozen_at, 'freezing is once-per-episode: a second saturated tick keeps the original wait timestamp');
|
|
104
125
|
});
|
|
126
|
+
test('a durable daemon message reports capacity_frozen when its wake cannot launch', async () => {
|
|
127
|
+
saturate();
|
|
128
|
+
const waiting = node('message-frozen');
|
|
129
|
+
const delivery = await deliverNodeMessage({
|
|
130
|
+
node_id: waiting,
|
|
131
|
+
body: 'capacity-delayed notice',
|
|
132
|
+
label: 'notice',
|
|
133
|
+
mode: 'wake',
|
|
134
|
+
});
|
|
135
|
+
assert.equal(delivery.via, 'inbox');
|
|
136
|
+
assert.ok(delivery.entry_id !== null, 'the message stays durable while the broker waits');
|
|
137
|
+
assert.equal(delivery.revived, false);
|
|
138
|
+
assert.equal(delivery.reason, 'capacity_frozen');
|
|
139
|
+
});
|
|
105
140
|
test('an already-live node is admitted at the cap — it consumes no new slot', () => {
|
|
106
141
|
const live = node('already-live');
|
|
107
142
|
reviveNode(live, { resume: false, capacity: 'refuse' });
|
|
@@ -12,9 +12,14 @@
|
|
|
12
12
|
// by the daemon pass, so it belongs in the compiled seam tier.
|
|
13
13
|
import { after, before, test } from 'node:test';
|
|
14
14
|
import assert from 'node:assert/strict';
|
|
15
|
+
import { createNode } from '../../canvas/canvas.js';
|
|
15
16
|
import { closeDb } from '../../canvas/db.js';
|
|
16
17
|
import { isPidAlive } from '../../canvas/pid.js';
|
|
18
|
+
import { appendInbox } from '../../feed/inbox.js';
|
|
17
19
|
import { closeNode } from '../../runtime/close.js';
|
|
20
|
+
import { headlessBrokerHost } from '../../runtime/host.js';
|
|
21
|
+
import { transition } from '../../runtime/lifecycle.js';
|
|
22
|
+
import { reviveNode } from '../../runtime/revive.js';
|
|
18
23
|
import { spawnChild } from '../../runtime/spawn.js';
|
|
19
24
|
import { createHarness } from '../helpers/harness.js';
|
|
20
25
|
let h;
|
|
@@ -31,6 +36,34 @@ after(async () => {
|
|
|
31
36
|
await h.dispose();
|
|
32
37
|
delete process.env['CRTR_MAX_LIVE_BROKERS'];
|
|
33
38
|
});
|
|
39
|
+
test('node new reports a frozen delegated birth without claiming its broker started', { timeout: 120_000 }, async () => {
|
|
40
|
+
const created = [];
|
|
41
|
+
try {
|
|
42
|
+
for (const prompt of ['first API child', 'second API child']) {
|
|
43
|
+
const result = h.cli(root, ['--json', 'node', 'new', '--kind', 'general', prompt]);
|
|
44
|
+
assert.equal(result.code, 0, `node new should succeed\n${result.stderr}`);
|
|
45
|
+
const nodeId = result.json.node_id;
|
|
46
|
+
created.push(nodeId);
|
|
47
|
+
await h.awaitBoot(nodeId);
|
|
48
|
+
}
|
|
49
|
+
const frozen = h.cli(root, ['--json', 'node', 'new', '--kind', 'general', 'frozen API child']);
|
|
50
|
+
assert.equal(frozen.code, 0, `node new should record the frozen birth\n${frozen.stderr}`);
|
|
51
|
+
const output = frozen.json;
|
|
52
|
+
created.push(output.node_id);
|
|
53
|
+
assert.ok(output.frozen_at !== undefined, 'JSON reports the durable frozen birth detail');
|
|
54
|
+
assert.match(output.follow_up, /Child row exists, but no broker started because capacity is full/);
|
|
55
|
+
const prose = h.cli(root, ['node', 'new', '--kind', 'general', 'another frozen API child']);
|
|
56
|
+
assert.equal(prose.code, 0, `node new should report capacity in prose\n${prose.stderr}`);
|
|
57
|
+
assert.match(prose.stdout, /Child row exists, but no broker started because capacity is full/);
|
|
58
|
+
const proseNode = prose.stdout.match(/\(([^)]+)\)/)?.[1];
|
|
59
|
+
assert.ok(proseNode !== undefined, 'prose names the created frozen row');
|
|
60
|
+
created.push(proseNode);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
for (const nodeId of created)
|
|
64
|
+
h.cli(root, ['node', 'lifecycle', 'close', '--node', nodeId]);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
34
67
|
test('a birth at the cap freezes instead of booting, survives saturated ticks, and thaws when a slot frees', { timeout: 120_000 }, async () => {
|
|
35
68
|
const first = await h.spawnHeadlessChild(root, 'first live child');
|
|
36
69
|
const second = await h.spawnHeadlessChild(root, 'second live child');
|
|
@@ -78,4 +111,44 @@ test('a birth at the cap freezes instead of booting, survives saturated ticks, a
|
|
|
78
111
|
assert.equal(h.fleet.has(third), true, 'the freed slot is now the thawed node\u2019s');
|
|
79
112
|
assert.equal(h.fleet.has(second), true, 'the untouched live node kept its slot throughout');
|
|
80
113
|
assert.notEqual(thawed.pi_pid, firstPid, 'the thaw is a real new broker, not the finished node\u2019s pid lingering');
|
|
114
|
+
transition(second, 'park');
|
|
115
|
+
headlessBrokerHost.teardown(second);
|
|
116
|
+
await h.awaitFleetExit(second);
|
|
117
|
+
const filler = await h.spawnHeadlessChild(root, 'terminal thaw filler');
|
|
118
|
+
assert.equal(h.fleet.size(), 2, 'the parked target is non-live while the cap remains full');
|
|
119
|
+
appendInbox(second, { from: root, tier: 'normal', kind: 'message', label: 'durable wake' });
|
|
120
|
+
assert.equal(reviveNode(second, { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
121
|
+
closeDb();
|
|
122
|
+
const frozenTerminal = h.node(second);
|
|
123
|
+
assert.equal(frozenTerminal.status, 'done', 'freezing preserves the terminal status');
|
|
124
|
+
assert.equal(frozenTerminal.intent, 'parked', 'freezing preserves the terminal intent');
|
|
125
|
+
assert.ok(frozenTerminal.frozen_at != null, 'the durable wake remains in the bounded terminal tick population');
|
|
126
|
+
headlessBrokerHost.teardown(filler);
|
|
127
|
+
await h.awaitFleetExit(filler);
|
|
128
|
+
await h.tick();
|
|
129
|
+
await h.awaitBoot(second, { minCount: 2 });
|
|
130
|
+
closeDb();
|
|
131
|
+
assert.equal(h.node(second).frozen_at ?? null, null, 'a freed slot thaws the frozen terminal wake');
|
|
132
|
+
assert.equal(h.fleet.has(second), true, 'the terminal wake receives the freed slot');
|
|
133
|
+
createNode({
|
|
134
|
+
node_id: 'resident-drain',
|
|
135
|
+
name: 'resident drain',
|
|
136
|
+
kind: 'general',
|
|
137
|
+
mode: 'base',
|
|
138
|
+
lifecycle: 'resident',
|
|
139
|
+
status: 'done',
|
|
140
|
+
intent: 'parked',
|
|
141
|
+
cwd: h.node(third).cwd,
|
|
142
|
+
host_kind: 'broker',
|
|
143
|
+
parent: root,
|
|
144
|
+
created: new Date().toISOString(),
|
|
145
|
+
pi_session_id: 'resident-drain-session',
|
|
146
|
+
});
|
|
147
|
+
assert.equal(reviveNode('resident-drain', { resume: true, capacity: 'freeze' }).outcome, 'frozen');
|
|
148
|
+
await h.tick();
|
|
149
|
+
closeDb();
|
|
150
|
+
const drained = h.node('resident-drain');
|
|
151
|
+
assert.equal(drained.status, 'idle', 'an eligible resident drains to Dormant');
|
|
152
|
+
assert.equal(drained.frozen_at ?? null, null, 'draining retires the frozen mark');
|
|
153
|
+
assert.equal(h.fleet.size(), 2, 'the drain does not spend a live-broker slot');
|
|
81
154
|
});
|
|
@@ -181,8 +181,7 @@ test('an unattended resident with no live obligation completes on the daemon clo
|
|
|
181
181
|
// Clock expiry: arms the pending park and requests the summary turn.
|
|
182
182
|
await h.tick(seenAt + 1500);
|
|
183
183
|
// Summary grace expiry: the degraded enactment parks without a report.
|
|
184
|
-
//
|
|
185
|
-
// api-server process, the dormant-inbox reconciler applies the same park.)
|
|
184
|
+
// The lifecycle tick applies the same park after a settled release.
|
|
186
185
|
await h.tick(seenAt + 1500 + 1000);
|
|
187
186
|
await h.waitFor(() => {
|
|
188
187
|
const node = h.node(nodeId);
|
|
@@ -55,15 +55,6 @@ export declare function buildTree(rows: DashboardRow[], rootIds: string[], child
|
|
|
55
55
|
* reaped node's parent so a non-leaf reap never orphans them out of view. No-op
|
|
56
56
|
* if the id is absent. */
|
|
57
57
|
export declare function pruneNode(tree: Tree, id: string): void;
|
|
58
|
-
/** Case-insensitive subsequence match: every char of `query` appears in `text`
|
|
59
|
-
* in order (gaps allowed). Empty query matches everything. Substrings are a
|
|
60
|
-
* subsequence, so this subsumes substring matching too. */
|
|
61
|
-
export declare function fuzzyMatch(query: string, text: string): boolean;
|
|
62
|
-
/** Indices in `text` consumed by a greedy left-to-right subsequence match of
|
|
63
|
-
* `query` — the same walk as `fuzzyMatch`, but returning WHICH chars matched so
|
|
64
|
-
* the renderer can highlight them. Empty set when `query` is empty OR does not
|
|
65
|
-
* fully match (no partial highlights). */
|
|
66
|
-
export declare function matchIndices(query: string, text: string): Set<number>;
|
|
67
58
|
/** One word-wrapped preview line: its text + the column indices WITHIN that text
|
|
68
59
|
* to highlight (the query match). */
|
|
69
60
|
export interface SnippetLine {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// access (dashboardRowsAll / listNodes / subscriptionsOf) into buildTree, then
|
|
6
6
|
// drives flatten() on each keystroke.
|
|
7
7
|
import { isFinishedWork, isLiveStatus, statusRank } from '../node-order.js';
|
|
8
|
+
import { matchIndices } from '../../tui/fuzzy.js';
|
|
8
9
|
export const TABS = ['All', 'Live', 'Dormant', 'Attention', 'Pinned'];
|
|
9
10
|
export const SORTS = ['tree', 'attention', 'relevance', 'recency'];
|
|
10
11
|
/** Does a node belong to this tab's slice?
|
|
@@ -119,43 +120,6 @@ export function pruneNode(tree, id) {
|
|
|
119
120
|
}
|
|
120
121
|
tree.nodes.delete(id);
|
|
121
122
|
}
|
|
122
|
-
// Fuzzy match
|
|
123
|
-
/** Case-insensitive subsequence match: every char of `query` appears in `text`
|
|
124
|
-
* in order (gaps allowed). Empty query matches everything. Substrings are a
|
|
125
|
-
* subsequence, so this subsumes substring matching too. */
|
|
126
|
-
export function fuzzyMatch(query, text) {
|
|
127
|
-
if (query === '')
|
|
128
|
-
return true;
|
|
129
|
-
const q = query.toLowerCase();
|
|
130
|
-
const t = text.toLowerCase();
|
|
131
|
-
let qi = 0;
|
|
132
|
-
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
133
|
-
if (t[ti] === q[qi])
|
|
134
|
-
qi++;
|
|
135
|
-
}
|
|
136
|
-
return qi === q.length;
|
|
137
|
-
}
|
|
138
|
-
/** Indices in `text` consumed by a greedy left-to-right subsequence match of
|
|
139
|
-
* `query` — the same walk as `fuzzyMatch`, but returning WHICH chars matched so
|
|
140
|
-
* the renderer can highlight them. Empty set when `query` is empty OR does not
|
|
141
|
-
* fully match (no partial highlights). */
|
|
142
|
-
export function matchIndices(query, text) {
|
|
143
|
-
const out = new Set();
|
|
144
|
-
if (query === '')
|
|
145
|
-
return out;
|
|
146
|
-
const q = query.toLowerCase();
|
|
147
|
-
const t = text.toLowerCase();
|
|
148
|
-
let qi = 0;
|
|
149
|
-
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
150
|
-
if (t[ti] === q[qi]) {
|
|
151
|
-
out.add(ti);
|
|
152
|
-
qi++;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (qi < q.length)
|
|
156
|
-
out.clear(); // no full match → no highlight
|
|
157
|
-
return out;
|
|
158
|
-
}
|
|
159
123
|
/** Chars of leading context kept before the match start when windowing. */
|
|
160
124
|
const SNIPPET_LEAD = 12;
|
|
161
125
|
/** The literal case-insensitive substring span in a conversation preview.
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// reverse) is allowed always. See detectColorCaps() for the gate, and the
|
|
13
13
|
// canvas-browse color spec for the rationale + palette.
|
|
14
14
|
import { STATUS_GLYPH, STATUS_COLOR, isFrozenRow, resolveNodeVisual, faultSummary } from '../status-glyph.js';
|
|
15
|
-
import { TABS,
|
|
15
|
+
import { TABS, promptText, previewSnippet } from './model.js';
|
|
16
|
+
import { matchIndices } from '../../tui/fuzzy.js';
|
|
16
17
|
// Span/color primitives live in core/tui/draw.ts (one copy, shared with the
|
|
17
18
|
// `crtr view` host). Re-export the color caps so browse's importers + tests keep
|
|
18
19
|
// resolving them from this module.
|
|
@@ -129,11 +129,11 @@ export declare function migrateLegacyPidIdentities(captureIdentity?: (pid: numbe
|
|
|
129
129
|
* Unlike `listNodes`, this includes unclaimed warm-pool spares: profile
|
|
130
130
|
* deletion owns every matching row, whether or not it is user-visible. */
|
|
131
131
|
export declare function listNodesByProfile(profileId: string): NodeMeta[];
|
|
132
|
-
/** All rows, optionally filtered by status and
|
|
133
|
-
* supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
|
|
132
|
+
/** All rows, optionally filtered by status and terminal execution traces. Unclaimed warm spares are excluded. */
|
|
134
133
|
export declare function listNodes(filter?: {
|
|
135
134
|
status?: NodeStatus | NodeStatus[];
|
|
136
135
|
withRecordedPid?: boolean;
|
|
136
|
+
withRecordedPidOrFrozen?: boolean;
|
|
137
137
|
}): NodeRow[];
|
|
138
138
|
/** Mark an already-spawned node as an unclaimed spare for `recipeKey`. Hides it
|
|
139
139
|
* from every listing until it is claimed. */
|
|
@@ -170,8 +170,8 @@ export declare function isWarmSpare(nodeId: string): boolean;
|
|
|
170
170
|
* pool has none. The select+delete run inside ONE canvas write-lock
|
|
171
171
|
* transaction, so two concurrent creates can never be handed the same spare.
|
|
172
172
|
* A spare whose broker is gone is skipped here and reaped by
|
|
173
|
-
* `reapStaleSpares` — nothing ever resumes a spare (the
|
|
174
|
-
*
|
|
173
|
+
* `reapStaleSpares` — nothing ever resumes a spare (the daemon's row query
|
|
174
|
+
* hides them), so a dead spare is garbage, not a parked one.
|
|
175
175
|
*
|
|
176
176
|
* The claim also restamps the row's `created` to `created` — birth time is a
|
|
177
177
|
* spare's MINT time, and consumers sort conversations on it, so an hour-old
|
|
@@ -291,7 +291,7 @@ export type TerminalGuardResult<T> = {
|
|
|
291
291
|
/** Close the TOCTOU between checking a target has a natural cycle ahead
|
|
292
292
|
* of it and the write that depends on that (for example, a deferred inbox
|
|
293
293
|
* append). Fresh-reads `nodeId`'s
|
|
294
|
-
* (status, final_report) and either runs `body` (a write) or short-circuits
|
|
294
|
+
* (status, final_report, frozen_at) and either runs `body` (a write) or short-circuits
|
|
295
295
|
* with a `'terminal'`/`'missing'` outcome — the read and `body`'s write share
|
|
296
296
|
* ONE canvas write-lock boundary (`withCanvasWrite`'s `BEGIN IMMEDIATE`), so
|
|
297
297
|
* a concurrent finish/cancel/finalization/deletion either commits entirely
|
|
@@ -311,6 +311,7 @@ export type TerminalGuardResult<T> = {
|
|
|
311
311
|
export declare function withFreshTerminalGuard<T>(nodeId: string, isTerminal: (row: {
|
|
312
312
|
status: NodeStatus;
|
|
313
313
|
final_report: string | null;
|
|
314
|
+
frozen_at: string | null;
|
|
314
315
|
}) => boolean, body: () => T): TerminalGuardResult<T>;
|
|
315
316
|
/** Rebuild node rows from on-disk metas (the db node table is a derived index).
|
|
316
317
|
* Only the IDENTITY columns are rebuilt — they are a projection of meta. The
|
|
@@ -19,7 +19,7 @@ import { ensureHome, ensureNodeDirs, nodeMetaPath, nodeDir, nodesRoot, jobDir, }
|
|
|
19
19
|
/** The identity keys meta.json persists. Listed explicitly so no runtime field
|
|
20
20
|
* can ever leak onto disk even when a fully-hydrated NodeMeta is handed in. */
|
|
21
21
|
const IDENTITY_KEYS = [
|
|
22
|
-
'node_id', 'name', 'description', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
|
|
22
|
+
'node_id', 'name', 'description', 'title', 'icon', 'cycles', 'created', 'cwd', 'host_kind', 'profile_id', 'kind', 'mode',
|
|
23
23
|
'lifecycle', 'persona_ack', 'parent', 'spawned_by', 'fork_from', 'fork_source_file', 'review_binding', 'passive_default', 'pi_session_id',
|
|
24
24
|
'pi_session_file', 'cycle_pending', 'model_override',
|
|
25
25
|
'launch', 'managed_worktree',
|
|
@@ -456,8 +456,7 @@ export function listNodesByProfile(profileId) {
|
|
|
456
456
|
return meta;
|
|
457
457
|
});
|
|
458
458
|
}
|
|
459
|
-
/** All rows, optionally filtered by status and
|
|
460
|
-
* supervision tick's terminal-row query). Unclaimed warm spares are excluded. */
|
|
459
|
+
/** All rows, optionally filtered by status and terminal execution traces. Unclaimed warm spares are excluded. */
|
|
461
460
|
export function listNodes(filter) {
|
|
462
461
|
const clauses = [NOT_A_SPARE];
|
|
463
462
|
const params = [];
|
|
@@ -468,6 +467,8 @@ export function listNodes(filter) {
|
|
|
468
467
|
}
|
|
469
468
|
if (filter?.withRecordedPid === true)
|
|
470
469
|
clauses.push('pi_pid IS NOT NULL');
|
|
470
|
+
if (filter?.withRecordedPidOrFrozen === true)
|
|
471
|
+
clauses.push('(pi_pid IS NOT NULL OR frozen_at IS NOT NULL)');
|
|
471
472
|
const rows = openDb()
|
|
472
473
|
.prepare(`SELECT * FROM nodes WHERE ${clauses.join(' AND ')} ORDER BY created`)
|
|
473
474
|
.all(...params);
|
|
@@ -539,8 +540,8 @@ function spareIsClaimable(row) {
|
|
|
539
540
|
* pool has none. The select+delete run inside ONE canvas write-lock
|
|
540
541
|
* transaction, so two concurrent creates can never be handed the same spare.
|
|
541
542
|
* A spare whose broker is gone is skipped here and reaped by
|
|
542
|
-
* `reapStaleSpares` — nothing ever resumes a spare (the
|
|
543
|
-
*
|
|
543
|
+
* `reapStaleSpares` — nothing ever resumes a spare (the daemon's row query
|
|
544
|
+
* hides them), so a dead spare is garbage, not a parked one.
|
|
544
545
|
*
|
|
545
546
|
* The claim also restamps the row's `created` to `created` — birth time is a
|
|
546
547
|
* spare's MINT time, and consumers sort conversations on it, so an hour-old
|
|
@@ -763,7 +764,7 @@ export function settleDeadMessageWait(nodeId, controller, deliver) {
|
|
|
763
764
|
/** Close the TOCTOU between checking a target has a natural cycle ahead
|
|
764
765
|
* of it and the write that depends on that (for example, a deferred inbox
|
|
765
766
|
* append). Fresh-reads `nodeId`'s
|
|
766
|
-
* (status, final_report) and either runs `body` (a write) or short-circuits
|
|
767
|
+
* (status, final_report, frozen_at) and either runs `body` (a write) or short-circuits
|
|
767
768
|
* with a `'terminal'`/`'missing'` outcome — the read and `body`'s write share
|
|
768
769
|
* ONE canvas write-lock boundary (`withCanvasWrite`'s `BEGIN IMMEDIATE`), so
|
|
769
770
|
* a concurrent finish/cancel/finalization/deletion either commits entirely
|
|
@@ -783,7 +784,7 @@ export function settleDeadMessageWait(nodeId, controller, deliver) {
|
|
|
783
784
|
export function withFreshTerminalGuard(nodeId, isTerminal, body) {
|
|
784
785
|
return withCanvasWrite((db) => {
|
|
785
786
|
const row = db
|
|
786
|
-
.prepare('SELECT status, final_report FROM nodes WHERE node_id = ?')
|
|
787
|
+
.prepare('SELECT status, final_report, frozen_at FROM nodes WHERE node_id = ?')
|
|
787
788
|
.get(nodeId);
|
|
788
789
|
if (row === undefined) {
|
|
789
790
|
return { kind: 'missing' };
|
|
@@ -90,6 +90,12 @@ export interface NodeIdentity {
|
|
|
90
90
|
/** A 2-4 word kebab-case handle derived from the node's first prompt (named
|
|
91
91
|
* headlessly by pi; see runtime/naming.ts). Shown in the editor label. */
|
|
92
92
|
description?: string;
|
|
93
|
+
/** The same first prompt named as PROSE — a sentence-case phrase with its
|
|
94
|
+
* punctuation intact, produced by the same headless namer in the same call as
|
|
95
|
+
* `description`. Kebab case is right for a tmux window and an editor label
|
|
96
|
+
* and wrong for a conversation list, so a surface reading to a person takes
|
|
97
|
+
* this. Absent on a node named before titles existed. */
|
|
98
|
+
title?: string;
|
|
93
99
|
/** A single Nerd Font glyph depicting the node's work, chosen by the same
|
|
94
100
|
* headless namer that produced `description` (see runtime/naming.ts). Kept as
|
|
95
101
|
* its own field rather than baked into the description so each surface decides
|
|
@@ -177,7 +177,7 @@ export async function realizeReview(reviewId) {
|
|
|
177
177
|
throw companionUnbound(review.review_id, COMPANION_BIND_DEADLINE_MS);
|
|
178
178
|
}
|
|
179
179
|
// R5: SQLite visibility commits before the ticket projection. A missing
|
|
180
|
-
// projection is deliberately under-exposed and
|
|
180
|
+
// projection is deliberately under-exposed and retried by the review lane.
|
|
181
181
|
if (openReviewRow(review.review_id, new Date().toISOString())) {
|
|
182
182
|
signalReviewActivity();
|
|
183
183
|
if (review.origin_kind === 'ticket')
|