@north-light/crouter 0.3.241 → 0.3.243
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 +1 -1
- package/dist/commands/node/create.js +8 -3
- package/dist/commands/sys/context/admin/docs-panel.d.ts +17 -7
- package/dist/commands/sys/context/admin/docs-panel.js +43 -23
- package/dist/commands/sys/context/admin/list-view.d.ts +22 -11
- package/dist/commands/sys/context/admin/list-view.js +112 -53
- package/dist/commands/sys/context/admin/shell.d.ts +3 -0
- package/dist/commands/sys/context/admin/shell.js +11 -2
- 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/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/warm-pool.js +4 -4
- 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
|
@@ -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);
|
|
@@ -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')
|
|
@@ -206,121 +206,132 @@ export const headlessBrokerHost = {
|
|
|
206
206
|
if (!fleet.reserveSlot(nodeId)) {
|
|
207
207
|
throw brokerCapReached(brokerThresholdsForDaemon().automaticReviveCap, fleet.size());
|
|
208
208
|
}
|
|
209
|
-
|
|
210
|
-
// node's existing job/ dir. It retains arbitrary engine/process residue and
|
|
211
|
-
// failed-canonical fatal fallback output. Append-mode preserves crash-revive history.
|
|
212
|
-
const logDir = jobDir(nodeId);
|
|
213
|
-
mkdirSync(logDir, { recursive: true });
|
|
214
|
-
const logFd = openSync(join(logDir, 'broker.log'), 'a');
|
|
215
|
-
// Launch from the crouter-branded host binary (a copy of node) so the
|
|
216
|
-
// broker shows "crouter" in macOS Full Disk Access, not "node". On
|
|
217
|
-
// non-darwin / dev / tests this is just process.execPath (see branded-host).
|
|
218
|
-
// buildBrokerEnv's default-deny allowlist has no `CRTR_` prefix, so a bare
|
|
219
|
-
// `CRTR_BROKER_ENGINE` set only in the LAUNCHING process's ambient env (the
|
|
220
|
-
// T11 test seam — see broker-sdk.ts) would never reach the child, which
|
|
221
|
-
// would silently fall back to booting the real SDK. Inject the SAME value
|
|
222
|
-
// preflightBrokerLaunch already resolved+validated above: it is
|
|
223
|
-
// parent-resolved, crtr-trusted data (the same trust category as
|
|
224
|
-
// `inv.env`), not a blanket `CRTR_*` passthrough, so this does not weaken
|
|
225
|
-
// the allowlist's hardening. In production `engineSpec` is always the real
|
|
226
|
-
// SDK default, so this is a no-op there.
|
|
227
|
-
const engineSpec = resolveEngineSpec(inv);
|
|
228
|
-
const childEnv = { ...buildBrokerEnv(inv), CRTR_BROKER_ENGINE: engineSpec };
|
|
229
|
-
// NODE_OPTIONS is deliberately absent from the operational allowlist (it is
|
|
230
|
-
// a code-execution vector — `--require`/`--import` — and DAEMON_ENV_STRIP_KEYS
|
|
231
|
-
// strips it from the daemon's own env for the identical reason: a poisoned
|
|
232
|
-
// NODE_OPTIONS in an inheriting shell must never reach a process that then
|
|
233
|
-
// handles model credentials). It is genuinely needed ONLY for the T11 test
|
|
234
|
-
// seam above: the fake engine is a `.ts` fixture, and the broker CLI entry
|
|
235
|
-
// itself is only a `.js` file under the compiled `dist/`, so the harness
|
|
236
|
-
// relies on `NODE_OPTIONS=--import tsx/esm` (set on its own ambient env, same
|
|
237
|
-
// seam as CRTR_BROKER_ENGINE) to make the spawned child resolve both under
|
|
238
|
-
// tsx. Gating this carry on `engineSpec !== DEFAULT_BROKER_ENGINE` — i.e. only
|
|
239
|
-
// when the trusted test seam above already fired — keeps production (where
|
|
240
|
-
// the engine is never overridden) byte-identical to today: NODE_OPTIONS is
|
|
241
|
-
// never forwarded there, so an ambient poisoned value stays contained.
|
|
242
|
-
if (engineSpec !== DEFAULT_BROKER_ENGINE) {
|
|
243
|
-
const nodeOptions = inv.env['NODE_OPTIONS'] ?? process.env['NODE_OPTIONS'];
|
|
244
|
-
if (nodeOptions !== undefined)
|
|
245
|
-
childEnv['NODE_OPTIONS'] = nodeOptions;
|
|
246
|
-
}
|
|
247
|
-
if (eventSource() === undefined)
|
|
248
|
-
bindBrokerEventSource(nodeId);
|
|
249
|
-
// `--canvas-home` declares broker ownership in argv so the daemon's
|
|
250
|
-
// startup epoch census can match its own canvas' brokers exactly (crtrd's
|
|
251
|
-
// brokerCommandDeclaresCanvasHome) instead of inferring from install
|
|
252
|
-
// paths; `--epoch` stamps WHICH daemon epoch launched it, so the next
|
|
253
|
-
// daemon's census reaps every prior-epoch broker without adoption (D-8).
|
|
254
|
-
// Both precede the node id: the node id is by contract the FINAL argv
|
|
255
|
-
// token (both broker-cli and the ps census parse it that way).
|
|
209
|
+
let logFd = null;
|
|
256
210
|
let child;
|
|
211
|
+
let registered = false;
|
|
257
212
|
try {
|
|
258
|
-
|
|
213
|
+
// Redirect the detached broker's stdout+stderr to a per-node log under the
|
|
214
|
+
// node's existing job/ dir. It retains arbitrary engine/process residue and
|
|
215
|
+
// failed-canonical fatal fallback output. Append-mode preserves crash-revive history.
|
|
216
|
+
const logDir = jobDir(nodeId);
|
|
217
|
+
mkdirSync(logDir, { recursive: true });
|
|
218
|
+
logFd = openSync(join(logDir, 'broker.log'), 'a');
|
|
219
|
+
// Launch from the crouter-branded host binary (a copy of node) so the
|
|
220
|
+
// broker shows "crouter" in macOS Full Disk Access, not "node". On
|
|
221
|
+
// non-darwin / dev / tests this is just process.execPath (see branded-host).
|
|
222
|
+
// buildBrokerEnv's default-deny allowlist has no `CRTR_` prefix, so a bare
|
|
223
|
+
// `CRTR_BROKER_ENGINE` set only in the LAUNCHING process's ambient env (the
|
|
224
|
+
// T11 test seam — see broker-sdk.ts) would never reach the child, which
|
|
225
|
+
// would silently fall back to booting the real SDK. Inject the SAME value
|
|
226
|
+
// preflightBrokerLaunch already resolved+validated above: it is
|
|
227
|
+
// parent-resolved, crtr-trusted data (the same trust category as
|
|
228
|
+
// `inv.env`), not a blanket `CRTR_*` passthrough, so this does not weaken
|
|
229
|
+
// the allowlist's hardening. In production `engineSpec` is always the real
|
|
230
|
+
// SDK default, so this is a no-op there.
|
|
231
|
+
const engineSpec = resolveEngineSpec(inv);
|
|
232
|
+
const childEnv = { ...buildBrokerEnv(inv), CRTR_BROKER_ENGINE: engineSpec };
|
|
233
|
+
// NODE_OPTIONS is deliberately absent from the operational allowlist (it is
|
|
234
|
+
// a code-execution vector — `--require`/`--import` — and DAEMON_ENV_STRIP_KEYS
|
|
235
|
+
// strips it from the daemon's own env for the identical reason: a poisoned
|
|
236
|
+
// NODE_OPTIONS in an inheriting shell must never reach a process that then
|
|
237
|
+
// handles model credentials). It is genuinely needed ONLY for the T11 test
|
|
238
|
+
// seam above: the fake engine is a `.ts` fixture, and the broker CLI entry
|
|
239
|
+
// itself is only a `.js` file under the compiled `dist/`, so the harness
|
|
240
|
+
// relies on `NODE_OPTIONS=--import tsx/esm` (set on its own ambient env, same
|
|
241
|
+
// seam as CRTR_BROKER_ENGINE) to make the spawned child resolve both under
|
|
242
|
+
// tsx. Gating this carry on `engineSpec !== DEFAULT_BROKER_ENGINE` — i.e. only
|
|
243
|
+
// when the trusted test seam above already fired — keeps production (where
|
|
244
|
+
// the engine is never overridden) byte-identical to today: NODE_OPTIONS is
|
|
245
|
+
// never forwarded there, so an ambient poisoned value stays contained.
|
|
246
|
+
if (engineSpec !== DEFAULT_BROKER_ENGINE) {
|
|
247
|
+
const nodeOptions = inv.env['NODE_OPTIONS'] ?? process.env['NODE_OPTIONS'];
|
|
248
|
+
if (nodeOptions !== undefined)
|
|
249
|
+
childEnv['NODE_OPTIONS'] = nodeOptions;
|
|
250
|
+
}
|
|
251
|
+
if (eventSource() === undefined)
|
|
252
|
+
bindBrokerEventSource(nodeId);
|
|
253
|
+
// `--canvas-home` declares broker ownership in argv so the daemon's
|
|
254
|
+
// startup epoch census can match its own canvas' brokers exactly (crtrd's
|
|
255
|
+
// brokerCommandDeclaresCanvasHome) instead of inferring from install
|
|
256
|
+
// paths; `--epoch` stamps WHICH daemon epoch launched it, so the next
|
|
257
|
+
// daemon's census reaps every prior-epoch broker without adoption (D-8).
|
|
258
|
+
// Both precede the node id: the node id is by contract the FINAL argv
|
|
259
|
+
// token (both broker-cli and the ps census parse it that way).
|
|
260
|
+
const spawned = spawn(hostExecPath(), [resolveBrokerEntry(), '--canvas-home', crtrHome(), '--epoch', fleet.epoch(), nodeId], {
|
|
259
261
|
cwd: opts.cwd,
|
|
260
262
|
detached: true,
|
|
261
263
|
stdio: ['ignore', logFd, logFd],
|
|
262
264
|
env: childEnv,
|
|
263
265
|
});
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
266
|
+
child = spawned;
|
|
267
|
+
// The child holds its own dup of the fd; release the parent's copy so the
|
|
268
|
+
// launching process (CLI or daemon) never leaks it.
|
|
267
269
|
closeSync(logFd);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
node_id: nodeId,
|
|
302
|
-
fields: { code, signal, uptime_ms: Date.now() - launchedAt },
|
|
270
|
+
logFd = null;
|
|
271
|
+
// ONE seam for every launch path (revive/spawn/recycle/reset): record the
|
|
272
|
+
// child's real exit status as a canonical event. Readiness waits consume
|
|
273
|
+
// `exited` for fail-fast and then discard it, so before this a broker that
|
|
274
|
+
// was SIGKILLed out from under crouter left NO trace anywhere — an external
|
|
275
|
+
// kill was indistinguishable from a silent early return, and post-mortems
|
|
276
|
+
// were unprovable (see the 2026-07-27 "brokers die at startup" RCA).
|
|
277
|
+
// Emitted through the ordinary bound source: in the daemon (the process
|
|
278
|
+
// that actually launches brokers) that is the daemon stream carrying
|
|
279
|
+
// `node_id`, which is how every other daemon-observed broker event
|
|
280
|
+
// (broker.wedge.detected, broker.yield_stall.*) is recorded and how a
|
|
281
|
+
// node-scoped log query finds it. The node's own job/log.jsonl is the
|
|
282
|
+
// BROKER's stream — the reader rejects any non-broker-component line there
|
|
283
|
+
// (events/read.ts descriptorMatches) — so the daemon must not write into
|
|
284
|
+
// it. A process already bound to this node's broker source (in-process
|
|
285
|
+
// revive) naturally lands there instead.
|
|
286
|
+
const launchedAt = Date.now();
|
|
287
|
+
const exited = new Promise((resolve) => {
|
|
288
|
+
let settled = false;
|
|
289
|
+
const finish = (state) => {
|
|
290
|
+
if (settled)
|
|
291
|
+
return;
|
|
292
|
+
settled = true;
|
|
293
|
+
resolve(state);
|
|
294
|
+
};
|
|
295
|
+
spawned.once('exit', (code, signal) => {
|
|
296
|
+
emitEvent({
|
|
297
|
+
level: code === 0 ? 'info' : 'warn',
|
|
298
|
+
event: 'broker.exited',
|
|
299
|
+
node_id: nodeId,
|
|
300
|
+
fields: { code, signal, uptime_ms: Date.now() - launchedAt },
|
|
301
|
+
});
|
|
302
|
+
finish({ code, signal });
|
|
303
303
|
});
|
|
304
|
-
finish({ code, signal });
|
|
304
|
+
spawned.once('error', () => finish({ code: null, signal: null }));
|
|
305
305
|
});
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
306
|
+
spawned.unref();
|
|
307
|
+
const handle = { pid: spawned.pid ?? null, exited };
|
|
308
|
+
// Register the live handle synchronously — the fleet entry IS the node's
|
|
309
|
+
// liveness from here on, and its real `exit` event (never a pid probe)
|
|
310
|
+
// drives dead-node policy. A null pid (spawn itself failed) registers
|
|
311
|
+
// nothing: the `exited` promise resolves via the 'error' event and the
|
|
312
|
+
// caller's own null-pid check surfaces the failure.
|
|
313
|
+
if (handle.pid !== null) {
|
|
314
|
+
fleet.register(nodeId, handle);
|
|
315
|
+
registered = true;
|
|
316
|
+
void exited.then((status) => fleet.onChildExit(nodeId, status));
|
|
317
|
+
}
|
|
318
|
+
return handle;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (!registered && child?.pid != null) {
|
|
322
|
+
try {
|
|
323
|
+
child.kill('SIGTERM');
|
|
324
|
+
}
|
|
325
|
+
catch { /* no fleet handle exists to tear down */ }
|
|
326
|
+
}
|
|
327
|
+
throw error;
|
|
318
328
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
329
|
+
finally {
|
|
330
|
+
if (!registered)
|
|
331
|
+
fleet.releaseReservation(nodeId);
|
|
332
|
+
if (logFd !== null)
|
|
333
|
+
closeSync(logFd);
|
|
322
334
|
}
|
|
323
|
-
return handle;
|
|
324
335
|
},
|
|
325
336
|
isAlive(node) {
|
|
326
337
|
return isPidAlive((typeof node === 'string' ? getNode(node) : node)?.pi_pid);
|
|
@@ -63,8 +63,9 @@ const TRANSITIONS = {
|
|
|
63
63
|
crash: { status: 'dead', from: LIVE },
|
|
64
64
|
// requestYield · relaunchRoot new-node safety net. Status KEPT (already active).
|
|
65
65
|
yield: { intent: 'refresh', from: LIVE },
|
|
66
|
-
//
|
|
67
|
-
|
|
66
|
+
// Free the host, stay woken by the inbox. A parked resident can also drain
|
|
67
|
+
// here after a capacity freeze without spending a slot.
|
|
68
|
+
release: { status: 'idle', intent: 'idle-release', from: [...LIVE, 'done'] },
|
|
68
69
|
// reviveNode provisional refresh launch: activate without committing the
|
|
69
70
|
// refresh until session_start proves the engine booted.
|
|
70
71
|
'refresh-launch': { status: 'active', intent: 'refresh', from: ANY },
|
|
@@ -2,8 +2,18 @@
|
|
|
2
2
|
* usable survives. Lowercases, keeps [a-z0-9], collapses everything else to a
|
|
3
3
|
* single hyphen, and clamps to the first 8 words. */
|
|
4
4
|
export declare function sanitizeSessionName(raw: string): string;
|
|
5
|
-
/**
|
|
6
|
-
*
|
|
5
|
+
/** Coerce arbitrary text into a one-line prose title, or '' if nothing usable
|
|
6
|
+
* survives. Takes the first non-empty line, collapses runs of whitespace, drops
|
|
7
|
+
* wrapping quotes, clamps at a word boundary, and capitalizes the opening
|
|
8
|
+
* letter. Punctuation INSIDE the line survives — carrying it is what `title`
|
|
9
|
+
* is for. */
|
|
10
|
+
export declare function sanitizeSessionTitle(raw: string): string;
|
|
11
|
+
/** Local fallback: the opening of the prompt as a title (no pi call), with its
|
|
12
|
+
* punctuation. Whitespace collapses first so a multi-line message contributes
|
|
13
|
+
* its actual opening words rather than whatever its first line happened to be. */
|
|
14
|
+
export declare function titleFromPrompt(prompt: string): string;
|
|
15
|
+
/** Local fallback: derive a kebab handle straight from the prompt (no pi call),
|
|
16
|
+
* from the same opening words the fallback title keeps. */
|
|
7
17
|
export declare function slugFromPrompt(prompt: string): string;
|
|
8
18
|
/** The namer's model: the `light` rung of the DEFAULT provider's ladder, with
|
|
9
19
|
* any thinking suffix stripped because the one-shot session uses thinking off.
|
|
@@ -11,11 +21,14 @@ export declare function slugFromPrompt(prompt: string): string;
|
|
|
11
21
|
* user who never configured Anthropic: an OpenAI default provider resolves to
|
|
12
22
|
* its own light rung. `CRTR_NAME_MODEL` still overrides. */
|
|
13
23
|
export declare function lightModel(overrideEnv?: string): string;
|
|
14
|
-
/** A generated session
|
|
15
|
-
* namer chose
|
|
16
|
-
*
|
|
24
|
+
/** A generated session label: the kebab-case handle, the prose title, and the
|
|
25
|
+
* Nerd Font glyph the namer chose. `title` is '' when the model gave nothing
|
|
26
|
+
* usable, leaving the caller to fall back to the prompt's own opening words;
|
|
27
|
+
* `icon` is '' on the same terms, and every surface renders the name alone in
|
|
28
|
+
* that case. */
|
|
17
29
|
export interface SessionName {
|
|
18
30
|
name: string;
|
|
31
|
+
title: string;
|
|
19
32
|
icon: string;
|
|
20
33
|
}
|
|
21
34
|
/** Normalize a model-supplied icon to a single renderable glyph, or '' when
|
|
@@ -30,9 +43,14 @@ export interface SessionName {
|
|
|
30
43
|
* `nf-fa-bug`, a bare ASCII letter, and anything multi-glyph, all of which
|
|
31
44
|
* would render as noise or tofu in a label. */
|
|
32
45
|
export declare function sanitizeIcon(raw: string): string;
|
|
33
|
-
/** Ask Pi headlessly for a structured {name, icon} for `body`, async.
|
|
34
|
-
* to a sanitized SessionName, or `{name:'',icon:''}` on
|
|
35
|
-
* call, or a malformed emission so the caller can fall
|
|
46
|
+
/** Ask Pi headlessly for a structured {name, title, icon} for `body`, async.
|
|
47
|
+
* Resolves to a sanitized SessionName, or `{name:'',title:'',icon:''}` on
|
|
48
|
+
* timeout, a missing tool call, or a malformed emission so the caller can fall
|
|
49
|
+
* back to the prompt's own words.
|
|
50
|
+
*
|
|
51
|
+
* Nothing FORCES the tool call — constrained sampling shapes the arguments once
|
|
52
|
+
* the model decides to call it — so a model that answers in prose instead gets
|
|
53
|
+
* one more attempt before the caller falls back.
|
|
36
54
|
*
|
|
37
55
|
* This module stays canvas-free so CLI leaves can reach it. The canvas-coupled
|
|
38
56
|
* commit path lives in pi-extensions/broker-local.ts, which imports this pure
|