@crouton-kit/crouter 0.3.68 → 0.3.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +31 -31
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +28 -8
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +11 -11
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -4
- package/dist/clients/attach/attach-cmd.js +667 -667
- package/dist/clients/attach/auth-pickers.d.ts +12 -0
- package/dist/clients/attach/auth-pickers.js +11 -0
- package/dist/clients/attach/pickers.js +2 -0
- package/dist/clients/attach/slash-commands.js +1 -0
- package/dist/commands/node.js +3 -3
- package/dist/commands/profile/new.js +30 -5
- package/dist/commands/sys/setup-core.js +2 -0
- package/dist/commands/sys/setup.js +132 -16
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
- package/dist/core/__tests__/full/cascade-close.test.js +5 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
- package/dist/core/__tests__/full/detach-focus.test.js +10 -5
- package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +28 -4
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
- package/dist/core/__tests__/review-model-floor.test.js +1 -1
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
- package/dist/core/canvas/pid.d.ts +14 -10
- package/dist/core/canvas/pid.js +14 -10
- package/dist/core/runtime/broker.js +2 -0
- package/dist/core/runtime/launch.js +1 -1
- package/dist/core/runtime/revive.js +28 -18
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/daemon/crtrd-cli.d.ts +1 -1
- package/dist/daemon/crtrd-cli.js +1 -0
- package/dist/suppress-experimental-warnings.d.ts +1 -0
- package/dist/suppress-experimental-warnings.js +15 -0
- package/dist/types.js +10 -10
- package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
- package/dist/web-client/assets/{index-CnF5r8ky.js → index-DiFuLcp6.js} +18 -17
- package/dist/web-client/index.html +3 -3
- package/dist/web-client/sw.js +1 -1
- package/package.json +10 -7
- package/scripts/postinstall.mjs +1 -1
- package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
|
@@ -45,12 +45,28 @@ const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
|
|
|
45
45
|
// A multi-word launcher, baked verbatim ahead of the (shell-quoted) argv by the
|
|
46
46
|
// seam. Absolute paths so it works regardless of the spawned window's cwd.
|
|
47
47
|
const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
|
|
48
|
+
// Every raw `tmux` call the harness/full-tier tests make against the shared
|
|
49
|
+
// DEFAULT server is synchronous (spawnSync). Node's spawnSync blocks the WHOLE
|
|
50
|
+
// worker (no event loop, no `node --test` timeout can preempt it) until the
|
|
51
|
+
// child exits — with no `timeout` option it can wait FOREVER. A wedged/CPU-
|
|
52
|
+
// starved tmux server (the acknowledged "intermittent Linux-only crash-revive
|
|
53
|
+
// wedge", CI-only) has been observed to hang one of these calls indefinitely,
|
|
54
|
+
// which permanently consumes one of the `--test-concurrency` slots; as more
|
|
55
|
+
// full-tier files each hit the SAME wedged server, every slot eventually gets
|
|
56
|
+
// consumed the same way and the whole suite flatlines with zero further TAP
|
|
57
|
+
// output (confirmed 3/3 in CI, see ci-full-suite-hang-blocker.md). TMUX_TIMEOUT_MS
|
|
58
|
+
// bounds every raw tmux spawnSync call so a wedge fails that ONE call (and the
|
|
59
|
+
// test it belongs to) fast instead of hanging the run.
|
|
60
|
+
export const TMUX_TIMEOUT_MS = 10_000;
|
|
48
61
|
/** True when a usable tmux is on PATH — tests gate on this and SKIP otherwise. */
|
|
49
62
|
export function hasTmux() {
|
|
50
|
-
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
63
|
+
return spawnSync('tmux', ['-V'], { stdio: 'ignore', timeout: TMUX_TIMEOUT_MS }).status === 0;
|
|
51
64
|
}
|
|
52
65
|
function tmuxSessionExists(session) {
|
|
53
|
-
return spawnSync('tmux', ['has-session', '-t', session], {
|
|
66
|
+
return (spawnSync('tmux', ['has-session', '-t', session], {
|
|
67
|
+
stdio: 'ignore',
|
|
68
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
69
|
+
}).status === 0);
|
|
54
70
|
}
|
|
55
71
|
// Every canvas/tmux var the harness itself runs under — scrubbed from each child
|
|
56
72
|
// env so a spawned CLI cannot leak the REAL canvas into the isolated test.
|
|
@@ -172,14 +188,19 @@ export async function createHarness(opts = {}) {
|
|
|
172
188
|
if (!headless) {
|
|
173
189
|
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 100000'], {
|
|
174
190
|
stdio: 'ignore',
|
|
191
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
175
192
|
});
|
|
176
193
|
// Put CRTR_PI_BINARY in the SESSION environment so EVERY pane/process spawned
|
|
177
194
|
// in this session inherits it — so any pi the runtime launches (boot, revive)
|
|
178
195
|
// resolves to the fake-pi vehicle, not a real pi.
|
|
179
196
|
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_PI_BINARY', FAKE_PI_BINARY], {
|
|
180
197
|
stdio: 'ignore',
|
|
198
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
199
|
+
});
|
|
200
|
+
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], {
|
|
201
|
+
stdio: 'ignore',
|
|
202
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
181
203
|
});
|
|
182
|
-
spawnSync('tmux', ['set-environment', '-t', session, 'CRTR_HOME', home], { stdio: 'ignore' });
|
|
183
204
|
}
|
|
184
205
|
const pidsToKill = new Set();
|
|
185
206
|
let nextRootSeq = 0;
|
|
@@ -517,7 +538,10 @@ export async function createHarness(opts = {}) {
|
|
|
517
538
|
return subscriptionsOf(nodeId).map((s) => ({ node_id: s.node_id, active: s.active }));
|
|
518
539
|
},
|
|
519
540
|
async dispose() {
|
|
520
|
-
spawnSync('tmux', ['kill-session', '-t', session], {
|
|
541
|
+
spawnSync('tmux', ['kill-session', '-t', session], {
|
|
542
|
+
stdio: 'ignore',
|
|
543
|
+
timeout: TMUX_TIMEOUT_MS,
|
|
544
|
+
});
|
|
521
545
|
// Best-effort: a superviseTick-triggered revive of a fabricated node fires
|
|
522
546
|
// a DETACHED throwaway broker (it loads the real SDK; the rmSync of home
|
|
523
547
|
// below makes a mid-boot one self-terminate when its files vanish). Kill
|
|
@@ -47,6 +47,7 @@ import { commitPersonaAck, personaDrift } from '../runtime/persona.js';
|
|
|
47
47
|
function persona(m) {
|
|
48
48
|
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
49
49
|
}
|
|
50
|
+
const FULL_CLI_TEST_TIMEOUT_MS = 180_000;
|
|
50
51
|
// ===========================================================================
|
|
51
52
|
// (b) THE demote / recycle SPLIT — two DISTINCT verbs:
|
|
52
53
|
// • `node lifecycle demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
|
|
@@ -60,9 +61,9 @@ function persona(m) {
|
|
|
60
61
|
// ===========================================================================
|
|
61
62
|
test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle recycle is FINISH+RECYCLE',
|
|
62
63
|
// Drives several full-CLI subprocess spawns (each a cold tsx compile of the
|
|
63
|
-
// whole CLI) + recycleNode;
|
|
64
|
-
//
|
|
65
|
-
{ timeout:
|
|
64
|
+
// whole CLI) + recycleNode; this is a subprocess-heavy regression and gets a
|
|
65
|
+
// CI-safe per-test budget while the npm script isolates it at file concurrency 1.
|
|
66
|
+
{ timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
|
|
66
67
|
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-demote' });
|
|
67
68
|
try {
|
|
68
69
|
const A = h.spawnRoot('resident root');
|
|
@@ -136,7 +137,7 @@ test('node lifecycle demote flips lifecycle→terminal IN PLACE; node lifecycle
|
|
|
136
137
|
// ===========================================================================
|
|
137
138
|
test('A4: a base→orchestrator yield auto-promotes + sets refresh but leaves persona_ack PENDING (the steer is never delivered)',
|
|
138
139
|
// Full-CLI subprocess spawn under load — see the sibling test's budget note.
|
|
139
|
-
{ timeout:
|
|
140
|
+
{ timeout: FULL_CLI_TEST_TIMEOUT_MS }, async () => {
|
|
140
141
|
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-live-a4' });
|
|
141
142
|
try {
|
|
142
143
|
const A = h.spawnRoot('resident root');
|
|
@@ -27,7 +27,7 @@ test('review still permits equal-or-stronger explicit models', () => {
|
|
|
27
27
|
assert.equal(spec('review', 'ultra'), normalizeModel('ultra'));
|
|
28
28
|
});
|
|
29
29
|
test('explore remains cheap by default and may explicitly run light', () => {
|
|
30
|
-
assert.equal(spec('explore'), normalizeModel('
|
|
30
|
+
assert.equal(spec('explore'), normalizeModel('openai/light'));
|
|
31
31
|
assert.equal(spec('explore', 'light'), normalizeModel('light'));
|
|
32
32
|
});
|
|
33
33
|
// `node new --model` widened to the full spec grammar (provider/tier, exact
|
|
@@ -45,7 +45,7 @@ async function waitFor(predicate, timeoutMs = 2000, intervalMs = 5) {
|
|
|
45
45
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async () => {
|
|
48
|
+
test('a listener fires once per known-kind SSE event, and never for a ping or an unknown kind', async (t) => {
|
|
49
49
|
const server = await startSseServer((res) => {
|
|
50
50
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
51
51
|
res.write(': connected\n\n');
|
|
@@ -55,15 +55,18 @@ test('a listener fires once per known-kind SSE event, and never for a ping or an
|
|
|
55
55
|
res.end();
|
|
56
56
|
});
|
|
57
57
|
const stream = new RemoteEventStream(server.url, 'tok');
|
|
58
|
+
// Register cleanup BEFORE any assertion runs — a thrown assertion must not
|
|
59
|
+
// leave the client's reconnect loop or the server's listening socket alive
|
|
60
|
+
// (see the CI-hang postmortem on rebuild-coalescer.test.ts).
|
|
61
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
58
62
|
const seen = [];
|
|
59
63
|
stream.subscribe((kind) => seen.push(kind));
|
|
60
64
|
stream.start();
|
|
61
65
|
await waitFor(() => seen.length > 0);
|
|
62
66
|
stream.stop();
|
|
63
67
|
assert.deepEqual(seen, ['nodes']);
|
|
64
|
-
await server.close();
|
|
65
68
|
});
|
|
66
|
-
test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async () => {
|
|
69
|
+
test('the SSE connection carries the token ONLY in the Authorization header — not in the URL or any other header', async (t) => {
|
|
67
70
|
const server = await startSseServer((res) => {
|
|
68
71
|
res.writeHead(200, { 'content-type': 'text/event-stream' });
|
|
69
72
|
res.end();
|
|
@@ -73,6 +76,7 @@ test('the SSE connection carries the token ONLY in the Authorization header —
|
|
|
73
76
|
// no header, including Host, is permitted to carry it.
|
|
74
77
|
const token = 'secret-sse-token-value';
|
|
75
78
|
const stream = new RemoteEventStream(server.url, token);
|
|
79
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
76
80
|
stream.start();
|
|
77
81
|
await waitFor(() => server.requests.length > 0);
|
|
78
82
|
stream.stop();
|
|
@@ -87,9 +91,8 @@ test('the SSE connection carries the token ONLY in the Authorization header —
|
|
|
87
91
|
assert.ok(!String(v).includes(token), `header ${k} must not carry the token`);
|
|
88
92
|
}
|
|
89
93
|
assert.ok(!req.url.includes(token));
|
|
90
|
-
await server.close();
|
|
91
94
|
});
|
|
92
|
-
test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async () => {
|
|
95
|
+
test('stop() cancels a pending reconnect timer — no zombie loop survives a stop()+start() cycle', async (t) => {
|
|
93
96
|
const server = await startSseServer((res) => {
|
|
94
97
|
res.writeHead(500);
|
|
95
98
|
res.end();
|
|
@@ -101,6 +104,7 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
|
|
|
101
104
|
// wide enough that ordinary event-loop/scheduler jitter can't flip it.
|
|
102
105
|
const BACKOFF = 200;
|
|
103
106
|
const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
|
|
107
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
104
108
|
stream.start();
|
|
105
109
|
// t≈0: first request fires+fails, backoff(BACKOFF) scheduled → stale deadline ≈ BACKOFF.
|
|
106
110
|
await waitFor(() => server.hitCount() >= 1);
|
|
@@ -119,10 +123,8 @@ test('stop() cancels a pending reconnect timer — no zombie loop survives a sto
|
|
|
119
123
|
// this window; a cleared one (fixed) leaves it at 2.
|
|
120
124
|
await new Promise((r) => setTimeout(r, BACKOFF * 0.7));
|
|
121
125
|
assert.equal(server.hitCount(), 2, "no zombie request from the stopped loop's dangling timer");
|
|
122
|
-
stream.stop();
|
|
123
|
-
await server.close();
|
|
124
126
|
});
|
|
125
|
-
test('stop() halts the reconnect loop — no further requests after it is called', async () => {
|
|
127
|
+
test('stop() halts the reconnect loop — no further requests after it is called', async (t) => {
|
|
126
128
|
const server = await startSseServer((res) => {
|
|
127
129
|
// Every connection is refused immediately (no SSE headers, just a close)
|
|
128
130
|
// so the client's loop falls into its backoff-and-retry path.
|
|
@@ -131,6 +133,7 @@ test('stop() halts the reconnect loop — no further requests after it is called
|
|
|
131
133
|
});
|
|
132
134
|
const BACKOFF = 20;
|
|
133
135
|
const stream = new RemoteEventStream(server.url, 'tok', { initialBackoffMs: BACKOFF });
|
|
136
|
+
t.after(() => { stream.stop(); return server.close(); });
|
|
134
137
|
stream.start();
|
|
135
138
|
await waitFor(() => server.hitCount() >= 1);
|
|
136
139
|
const hitsAtStop = server.hitCount();
|
|
@@ -140,5 +143,4 @@ test('stop() halts the reconnect loop — no further requests after it is called
|
|
|
140
143
|
// continuing to retry in the background after stop() returned.
|
|
141
144
|
await new Promise((r) => setTimeout(r, BACKOFF * 5));
|
|
142
145
|
assert.equal(server.hitCount(), hitsAtStop);
|
|
143
|
-
await server.close();
|
|
144
146
|
});
|
|
@@ -140,7 +140,7 @@ function seedPoisonedLocalState() {
|
|
|
140
140
|
].join('\n'));
|
|
141
141
|
return sf;
|
|
142
142
|
}
|
|
143
|
-
test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
143
|
+
test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource suppress every local-read field to its safe default for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
|
|
144
144
|
sessionFile = seedPoisonedLocalState();
|
|
145
145
|
const server = await startMockCrtrServer((args) => {
|
|
146
146
|
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
|
|
@@ -164,6 +164,10 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
164
164
|
}
|
|
165
165
|
return { status: 500, body: {} };
|
|
166
166
|
});
|
|
167
|
+
// Register the close BEFORE any assertion runs — a listening server left
|
|
168
|
+
// open by a thrown assertion keeps this test file's process alive forever
|
|
169
|
+
// (see the CI-hang postmortem on rebuild-coalescer.test.ts).
|
|
170
|
+
t.after(() => server.close());
|
|
167
171
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
168
172
|
const rows = await dashboardRowsAllFromSource(source);
|
|
169
173
|
assert.equal(rows.length, 1);
|
|
@@ -180,7 +184,6 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
180
184
|
assert.equal(previewed.prompts, undefined, 'a local session transcript must not leak into a remote preview');
|
|
181
185
|
assert.equal(previewed.lastAssistant, undefined, 'a local session transcript must not leak into a remote preview');
|
|
182
186
|
assert.equal(previewed.previewLoaded, true);
|
|
183
|
-
await server.close();
|
|
184
187
|
});
|
|
185
188
|
// The non-TTY `canvas browse` path (browse/app.ts's `if (!process.stdin.isTTY)`
|
|
186
189
|
// branch) calls `renderForestFromSource(source)` directly — a SEPARATE code
|
|
@@ -190,7 +193,7 @@ test('dashboardRowsAllFromSource / enrichRowsFromSource / loadPreviewFromSource
|
|
|
190
193
|
// test above never exercises it, so a regression that reintroduced local
|
|
191
194
|
// telemetry/ask/fault reads into the static forest renderer specifically would
|
|
192
195
|
// pass that test while still leaking poisoned local state here.
|
|
193
|
-
test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async () => {
|
|
196
|
+
test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) for a RemoteCanvasSource, even with conflicting local state for the same node id', async (t) => {
|
|
194
197
|
const sf = seedPoisonedLocalState();
|
|
195
198
|
const server = await startMockCrtrServer((args) => {
|
|
196
199
|
if (args[1] === 'node' && args[2] === 'inspect' && args[3] === 'list') {
|
|
@@ -211,11 +214,11 @@ test('renderForestFromSource suppresses local-read fields (ctx, asks, hanging) f
|
|
|
211
214
|
}
|
|
212
215
|
return { status: 500, body: {} };
|
|
213
216
|
});
|
|
217
|
+
t.after(() => server.close());
|
|
214
218
|
const source = new RemoteCanvasSource(server.url, 'tok');
|
|
215
219
|
const out = await renderForestFromSource(source);
|
|
216
220
|
assert.match(out, /ctx 0k/, 'the remote node must render with the suppressed ctx default, not a real token read');
|
|
217
221
|
assert.doesNotMatch(out, /⚑/, 'no ask badge — local asks must not leak into the static forest render');
|
|
218
222
|
assert.doesNotMatch(out, /POISON FAULT/, 'a local active fault must not leak into the static forest render');
|
|
219
223
|
assert.doesNotMatch(out, /POISON/, 'no poisoned local telemetry/goal/session text may appear anywhere in the output');
|
|
220
|
-
await server.close();
|
|
221
224
|
});
|
|
@@ -71,19 +71,36 @@ test('a burst of SSE-style triggers produces ONE round of remote requests, not N
|
|
|
71
71
|
res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
74
|
+
// The server MUST close in a `finally` — a listening TCP server keeps its
|
|
75
|
+
// process's event loop non-empty forever. Under `node --test`, every test
|
|
76
|
+
// file is its own child process; if an assertion in this test throws before
|
|
77
|
+
// the old inline `server.close()` at the end of the function ran, the
|
|
78
|
+
// server (and the process) never exited, wedging one `--test-concurrency`
|
|
79
|
+
// slot permanently and hanging the whole suite (see the CI-hang postmortem).
|
|
80
|
+
try {
|
|
81
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
82
|
+
const addr = server.address();
|
|
83
|
+
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
84
|
+
const source = new RemoteCanvasSource(`http://127.0.0.1:${port}`, 'tok');
|
|
85
|
+
const rebuilder = createCoalescedRebuilder(async () => {
|
|
86
|
+
source.invalidate();
|
|
87
|
+
await source.listNodes(); // one round of remote requests, mirroring app.ts's rebuildSnapshot
|
|
88
|
+
}, 20);
|
|
89
|
+
for (let i = 0; i < 6; i++)
|
|
90
|
+
rebuilder.trigger();
|
|
91
|
+
// Poll for the request to land instead of sleeping a fixed duration —
|
|
92
|
+
// deterministic under CI's variable CPU load, and doesn't slow down the
|
|
93
|
+
// common case (returns as soon as the debounce + request round trip land).
|
|
94
|
+
const deadline = Date.now() + 2000;
|
|
95
|
+
while (hits < 1 && Date.now() < deadline) {
|
|
96
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
97
|
+
}
|
|
98
|
+
assert.equal(hits, 1, 'a burst of 6 SSE events must produce ONE round of remote requests, not 6');
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
server.closeAllConnections();
|
|
102
|
+
await new Promise((r) => server.close(() => r()));
|
|
103
|
+
}
|
|
87
104
|
});
|
|
88
105
|
// Integration proof for browse/app.ts's ACTUAL wiring — not the coalescer
|
|
89
106
|
// helper in isolation. Every test above instantiates `createCoalescedRebuilder`
|
|
@@ -74,16 +74,20 @@ export declare function identitiesMatch(a: string, b: string): boolean;
|
|
|
74
74
|
* `identitiesMatch` says so, else `'dead'` (positively reused). */
|
|
75
75
|
export type RecordedPidLiveness = 'alive' | 'dead' | 'indeterminate';
|
|
76
76
|
export declare function recordedPidLiveness(pid: number | null | undefined, expectedIdentity: string | null | undefined): RecordedPidLiveness;
|
|
77
|
-
/** Boolean
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
77
|
+
/** Boolean confirmed-dead-direction adapter over `recordedPidLiveness`:
|
|
78
|
+
* `dead` is the ONLY thing that reads false. `indeterminate` (can't confirm
|
|
79
|
+
* either way) reads true alongside `alive` — never act on a maybe-ours pid
|
|
80
|
+
* on mere uncertainty, matching this module's universal discipline (a
|
|
81
|
+
* POSITIVE verdict is the only thing that changes behavior, never an
|
|
82
|
+
* absence of evidence). This is the correct read for BOTH signalling
|
|
83
|
+
* callers (never signal on uncertainty) and the double-launch guard in
|
|
84
|
+
* `reviveNode` (never RELAUNCH on uncertainty — the identity probe is a
|
|
85
|
+
* fork+exec that fails precisely under the OOM pressure where an extra
|
|
86
|
+
* broker is most destructive, while a truly dead pid always reads `dead`
|
|
87
|
+
* via the fork-free signal-0 path; see the guard's comment for the live
|
|
88
|
+
* incident this closed). Callers that need "confirmed alive" three-valued
|
|
89
|
+
* precision (e.g. the daemon's grace-clock bookkeeping) call
|
|
90
|
+
* `recordedPidLiveness` directly. */
|
|
87
91
|
export declare function isRecordedPidAlive(pid: number | null | undefined, expectedIdentity: string | null | undefined): boolean;
|
|
88
92
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
89
93
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -132,16 +132,20 @@ export function recordedPidLiveness(pid, expectedIdentity) {
|
|
|
132
132
|
logPidLivenessDecision(pid, alive, expectedIdentity, 'value', matches ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', matches ? 'alive' : 'dead', actual);
|
|
133
133
|
return matches ? 'alive' : 'dead';
|
|
134
134
|
}
|
|
135
|
-
/** Boolean
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
135
|
+
/** Boolean confirmed-dead-direction adapter over `recordedPidLiveness`:
|
|
136
|
+
* `dead` is the ONLY thing that reads false. `indeterminate` (can't confirm
|
|
137
|
+
* either way) reads true alongside `alive` — never act on a maybe-ours pid
|
|
138
|
+
* on mere uncertainty, matching this module's universal discipline (a
|
|
139
|
+
* POSITIVE verdict is the only thing that changes behavior, never an
|
|
140
|
+
* absence of evidence). This is the correct read for BOTH signalling
|
|
141
|
+
* callers (never signal on uncertainty) and the double-launch guard in
|
|
142
|
+
* `reviveNode` (never RELAUNCH on uncertainty — the identity probe is a
|
|
143
|
+
* fork+exec that fails precisely under the OOM pressure where an extra
|
|
144
|
+
* broker is most destructive, while a truly dead pid always reads `dead`
|
|
145
|
+
* via the fork-free signal-0 path; see the guard's comment for the live
|
|
146
|
+
* incident this closed). Callers that need "confirmed alive" three-valued
|
|
147
|
+
* precision (e.g. the daemon's grace-clock bookkeeping) call
|
|
148
|
+
* `recordedPidLiveness` directly. */
|
|
145
149
|
export function isRecordedPidAlive(pid, expectedIdentity) {
|
|
146
150
|
return recordedPidLiveness(pid, expectedIdentity) !== 'dead';
|
|
147
151
|
}
|
|
@@ -1086,6 +1086,8 @@ export async function runBroker(nodeId) {
|
|
|
1086
1086
|
showTerminalProgress: sm.getShowTerminalProgress(),
|
|
1087
1087
|
warnings: sm.getWarnings(),
|
|
1088
1088
|
defaultProjectTrust: sm.getDefaultProjectTrust(),
|
|
1089
|
+
showCacheMissNotices: sm.getShowCacheMissNotices(),
|
|
1090
|
+
outputPad: sm.getOutputPad(),
|
|
1089
1091
|
autoRetry: session.autoRetryEnabled,
|
|
1090
1092
|
model: toModelRef(session.model),
|
|
1091
1093
|
};
|
|
@@ -111,7 +111,7 @@ const STRENGTH_ALIASES = {
|
|
|
111
111
|
const MODEL_PROVIDER_KEYS = ['anthropic', 'openai'];
|
|
112
112
|
const MODEL_STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
|
|
113
113
|
const MODEL_STRENGTH_RANK = { light: 0, medium: 1, strong: 2, ultra: 3 };
|
|
114
|
-
const THINKING_SUFFIXES = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
|
|
114
|
+
const THINKING_SUFFIXES = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
115
115
|
function stripThinkingSuffix(spec) {
|
|
116
116
|
const i = spec.lastIndexOf(':');
|
|
117
117
|
if (i <= 0)
|
|
@@ -29,7 +29,7 @@ import { fanDoctrineWake } from './close.js';
|
|
|
29
29
|
import { buildPiArgv } from './launch.js';
|
|
30
30
|
import { buildReviveKickoff, drainBearings } from './kickoff.js';
|
|
31
31
|
import { headlessBrokerHost } from './host.js';
|
|
32
|
-
import {
|
|
32
|
+
import { isRecordedPidAlive } from '../canvas/pid.js';
|
|
33
33
|
import { reconcileBootLiveness } from '../canvas/boot.js';
|
|
34
34
|
import { clearFault } from './fault.js';
|
|
35
35
|
import { clearInjectedDocs } from '../substrate/injected-store.js';
|
|
@@ -118,24 +118,34 @@ export function reviveNode(nodeId, opts) {
|
|
|
118
118
|
const db = openDb();
|
|
119
119
|
db.exec('BEGIN IMMEDIATE');
|
|
120
120
|
try {
|
|
121
|
-
// Double-revive guard: identity-aware liveness on pi_pid
|
|
122
|
-
// CONFIRMED
|
|
123
|
-
// whose broker pid is
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
121
|
+
// Double-revive guard: identity-aware liveness on pi_pid. Only a
|
|
122
|
+
// CONFIRMED-dead read proceeds to relaunch; `alive` AND `indeterminate`
|
|
123
|
+
// both no-op. A node whose broker pid is still running as that same broker
|
|
124
|
+
// was already revived by another path — re-launching would put a SECOND
|
|
125
|
+
// broker on the same session file.
|
|
126
|
+
//
|
|
127
|
+
// Why `indeterminate` must NOT relaunch (live incident, 2026-07-10, 4GB
|
|
128
|
+
// Hearth home): the `dead` verdict comes from the fork-free signal-0
|
|
129
|
+
// syscall (`isPidAlive`), which cannot fail under resource pressure — a
|
|
130
|
+
// truly dead broker ALWAYS reads `dead`. `indeterminate` therefore means
|
|
131
|
+
// "the pid EXISTS but the `ps` identity probe failed" — and the probe is a
|
|
132
|
+
// fork+exec, so it fails exactly when the host is out of memory (ENOMEM)
|
|
133
|
+
// or thrashing (>2s timeout). Those failures are CORRELATED and
|
|
134
|
+
// persistent, not transient: under sustained OOM every guard read went
|
|
135
|
+
// `indeterminate`, every daemon grace window relaunched a broker whose
|
|
136
|
+
// predecessor was still alive, and `recordPid` below overwrote the row's
|
|
137
|
+
// pid — leaking each older broker untracked. That compounded to 7 live
|
|
138
|
+
// engines on one node (35 total on the home), which itself sustained the
|
|
139
|
+
// OOM that kept the probes failing. Relaunching on uncertainty is
|
|
140
|
+
// self-amplifying in precisely the regime where uncertainty occurs.
|
|
141
|
+
//
|
|
142
|
+
// The cost of no-opping on `indeterminate` is bounded and small: the only
|
|
143
|
+
// dead-but-indeterminate cases are a zombie or a reused pid whose `ps`
|
|
144
|
+
// probe failed — both re-read next daemon tick, and read `dead` the
|
|
145
|
+
// moment a probe succeeds. `isRecordedPidAlive` is the exact adapter for
|
|
146
|
+
// this direction (false ONLY on confirmed `dead`).
|
|
137
147
|
const live = getNode(nodeId) ?? meta;
|
|
138
|
-
if (
|
|
148
|
+
if (isRecordedPidAlive(live.pi_pid, live.pi_pid_identity)) {
|
|
139
149
|
return {
|
|
140
150
|
window: null,
|
|
141
151
|
session: live.tmux_session ?? null,
|
|
@@ -25,8 +25,12 @@ function startStalledServer() {
|
|
|
25
25
|
});
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
|
-
test('a stalled endpoint returns a timeout failure within the configured bound instead of hanging forever', async () => {
|
|
28
|
+
test('a stalled endpoint returns a timeout failure within the configured bound instead of hanging forever', async (t) => {
|
|
29
29
|
const server = await startStalledServer();
|
|
30
|
+
// Register the close BEFORE any assertion runs — a listening server left
|
|
31
|
+
// open by a thrown assertion keeps this test file's process alive forever
|
|
32
|
+
// (see the CI-hang postmortem on rebuild-coalescer.test.ts).
|
|
33
|
+
t.after(() => server.close());
|
|
30
34
|
const exec = createRemoteExec(server.url, 'tok', 100); // small timeout for the test
|
|
31
35
|
const started = Date.now();
|
|
32
36
|
// Race against a generous outer deadline: if the transport doesn't
|
|
@@ -40,9 +44,8 @@ test('a stalled endpoint returns a timeout failure within the configured bound i
|
|
|
40
44
|
assert.equal(result.ok, false);
|
|
41
45
|
assert.match(result.stderr, /timeout/i);
|
|
42
46
|
assert.ok(elapsed < 1000, `expected the timeout to fire near the configured 100ms bound, took ${elapsed}ms`);
|
|
43
|
-
await server.close();
|
|
44
47
|
});
|
|
45
|
-
test('a normal fast response is unaffected by the timeout wiring', async () => {
|
|
48
|
+
test('a normal fast response is unaffected by the timeout wiring', async (t) => {
|
|
46
49
|
const server = createServer((req, res) => {
|
|
47
50
|
const chunks = [];
|
|
48
51
|
req.on('data', (c) => chunks.push(c));
|
|
@@ -51,6 +54,7 @@ test('a normal fast response is unaffected by the timeout wiring', async () => {
|
|
|
51
54
|
res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
|
|
52
55
|
});
|
|
53
56
|
});
|
|
57
|
+
t.after(() => new Promise((r) => server.close(() => r())));
|
|
54
58
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
55
59
|
const addr = server.address();
|
|
56
60
|
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
@@ -58,7 +62,6 @@ test('a normal fast response is unaffected by the timeout wiring', async () => {
|
|
|
58
62
|
const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
|
|
59
63
|
assert.equal(result.ok, true);
|
|
60
64
|
assert.equal(result.stdout, JSON.stringify({ nodes: [] }));
|
|
61
|
-
await new Promise((r) => server.close(() => r()));
|
|
62
65
|
});
|
|
63
66
|
// --- Phase 3 review Minor 1: no token bytes may ever reach returned stderr ---
|
|
64
67
|
test('an unreachable endpoint returns a flat generic network-error message, never the raw exception text', async () => {
|
|
@@ -75,7 +78,7 @@ test('a malformed token (contains a newline) is rejected up front with a generic
|
|
|
75
78
|
assert.ok(!result.stderr.includes('bad\ntoken'));
|
|
76
79
|
assert.ok(!result.stderr.includes('bad'));
|
|
77
80
|
});
|
|
78
|
-
test('a valid token containing typical bearer-token punctuation (-, _, .) still round-trips against a real server', async () => {
|
|
81
|
+
test('a valid token containing typical bearer-token punctuation (-, _, .) still round-trips against a real server', async (t) => {
|
|
79
82
|
const server = createServer((req, res) => {
|
|
80
83
|
const chunks = [];
|
|
81
84
|
req.on('data', (c) => chunks.push(c));
|
|
@@ -84,6 +87,7 @@ test('a valid token containing typical bearer-token punctuation (-, _, .) still
|
|
|
84
87
|
res.end(JSON.stringify({ ok: true, exitCode: 0, stdout: JSON.stringify({ nodes: [] }), stderr: '' }));
|
|
85
88
|
});
|
|
86
89
|
});
|
|
90
|
+
t.after(() => new Promise((r) => server.close(() => r())));
|
|
87
91
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
88
92
|
const addr = server.address();
|
|
89
93
|
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
|
|
@@ -91,5 +95,4 @@ test('a valid token containing typical bearer-token punctuation (-, _, .) still
|
|
|
91
95
|
const result = await exec('crtr', ['--json', 'node', 'inspect', 'list']);
|
|
92
96
|
assert.equal(result.ok, true);
|
|
93
97
|
assert.equal(result.stdout, JSON.stringify({ nodes: [] }));
|
|
94
|
-
await new Promise((r) => server.close(() => r()));
|
|
95
98
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import '../suppress-experimental-warnings.js';
|
package/dist/daemon/crtrd-cli.js
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const originalEmit = process.emit;
|
|
2
|
+
process.emit = function (name, ...args) {
|
|
3
|
+
const [data] = args;
|
|
4
|
+
if (name === 'warning' &&
|
|
5
|
+
data instanceof Error &&
|
|
6
|
+
data.name === 'ExperimentalWarning' &&
|
|
7
|
+
data.message.includes('SQLite')) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
return originalEmit.apply(this, [
|
|
11
|
+
name,
|
|
12
|
+
...args,
|
|
13
|
+
]);
|
|
14
|
+
};
|
|
15
|
+
export {};
|
package/dist/types.js
CHANGED
|
@@ -57,12 +57,12 @@ export function defaultKindsConfig() {
|
|
|
57
57
|
},
|
|
58
58
|
explore: {
|
|
59
59
|
whenToUse: 'Map unfamiliar code or architecture — read-only orientation and code-path research with concrete file:line evidence. Anything diagnostic — investigating a failure, why something is broken or misbehaving, or live/runtime behavior — is advisor, not explore.',
|
|
60
|
-
model: '
|
|
60
|
+
model: 'openai/light',
|
|
61
61
|
},
|
|
62
62
|
developer: {
|
|
63
63
|
whenToUse: 'Implement a change — make the feature or fix genuinely work against its acceptance criteria, not merely compile.',
|
|
64
|
-
model: '
|
|
65
|
-
orchestratorModel: '
|
|
64
|
+
model: 'openai/medium',
|
|
65
|
+
orchestratorModel: 'openai/strong',
|
|
66
66
|
},
|
|
67
67
|
design: {
|
|
68
68
|
whenToUse: 'Architect a solution — produce one design document an implementer can build from without re-deciding anything left open.',
|
|
@@ -150,15 +150,15 @@ export function defaultModelLaddersConfig() {
|
|
|
150
150
|
return {
|
|
151
151
|
anthropic: {
|
|
152
152
|
ultra: 'anthropic/claude-fable-5:high',
|
|
153
|
-
strong: 'anthropic/claude-
|
|
154
|
-
medium: 'anthropic/claude-
|
|
155
|
-
light: 'anthropic/claude-
|
|
153
|
+
strong: 'anthropic/claude-fable-5:medium',
|
|
154
|
+
medium: 'anthropic/claude-opus-4-8:high',
|
|
155
|
+
light: 'anthropic/claude-sonnet-5:high',
|
|
156
156
|
},
|
|
157
157
|
openai: {
|
|
158
|
-
ultra: 'openai-codex/gpt-5.
|
|
159
|
-
strong: 'openai-codex/gpt-5.
|
|
160
|
-
medium: 'openai-codex/gpt-5.
|
|
161
|
-
light: 'openai-codex/gpt-5.
|
|
158
|
+
ultra: 'openai-codex/gpt-5.6-sol:max',
|
|
159
|
+
strong: 'openai-codex/gpt-5.6-sol:high',
|
|
160
|
+
medium: 'openai-codex/gpt-5.6-terra:medium',
|
|
161
|
+
light: 'openai-codex/gpt-5.6-luna:low',
|
|
162
162
|
},
|
|
163
163
|
};
|
|
164
164
|
}
|