@crouton-kit/crouter 0.3.67 → 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/build-root.js +1 -0
- 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 +668 -668
- 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/capture.d.ts +2 -0
- package/dist/commands/capture.js +28 -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/__tests__/revive.test.js +70 -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/command.d.ts +15 -0
- package/dist/core/command.js +72 -0
- package/dist/core/profiles/select.d.ts +4 -1
- package/dist/core/profiles/select.js +356 -81
- package/dist/core/runtime/broker.js +2 -0
- package/dist/core/runtime/launch.js +1 -1
- package/dist/core/runtime/revive.js +187 -140
- package/dist/core/runtime/tmux.js +4 -1
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/core/view/transport-local.js +12 -3
- 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-BvzxXXGU.js → index-DiFuLcp6.js} +19 -18
- 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
|
@@ -23,13 +23,13 @@
|
|
|
23
23
|
//
|
|
24
24
|
// reviveNode remains the ONLY sanctioned launcher of the node engine.
|
|
25
25
|
import { existsSync } from 'node:fs';
|
|
26
|
-
import { getNode, updateNode, clearPid, recordPid, fullName, cancelDeadlinesFor, subscribersOf, } from '../canvas/index.js';
|
|
26
|
+
import { getNode, updateNode, clearPid, recordPid, fullName, cancelDeadlinesFor, subscribersOf, openDb, } from '../canvas/index.js';
|
|
27
27
|
import { transition } from './lifecycle.js';
|
|
28
28
|
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';
|
|
@@ -89,147 +89,194 @@ export function reviveNode(nodeId, opts) {
|
|
|
89
89
|
// actually matters, so it's safe to call on every revive regardless of
|
|
90
90
|
// entry point (daemon tick, or a direct/manual `node lifecycle revive`).
|
|
91
91
|
reconcileBootLiveness();
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// `
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
updateNode(nodeId, { cycles: meta.cycles });
|
|
121
|
-
// Decide whether to wake the saved pi conversation, cycle it in place (a
|
|
122
|
-
// refresh-yield reusing the same `.jsonl`), or start fresh. resumeArgs
|
|
123
|
-
// resumes/cycles ONLY by the absolute session-file path (cwd-immune) and ONLY
|
|
124
|
-
// when that .jsonl actually exists; there is no bare-id fallback (the broker
|
|
125
|
-
// preflight rejects a bare `--session`). No file on disk yet ⇒ a fresh launch.
|
|
126
|
-
const resume = resumeArgs(meta, opts.resume);
|
|
127
|
-
// hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
|
|
128
|
-
// as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
|
|
129
|
-
// the latter case). cycling and resuming are mutually exclusive; `resuming`
|
|
130
|
-
// keeps the STRICT true-resume-only meaning `ReviveResult.resumed` and the
|
|
131
|
-
// launch-placement hint below have always carried.
|
|
132
|
-
const hasSessionPath = resume.resumeSessionPath !== undefined;
|
|
133
|
-
const cycling = hasSessionPath && resume.newCycle === true;
|
|
134
|
-
const resuming = hasSessionPath && !cycling;
|
|
135
|
-
// Mirror THIS launch attempt's cycling-ness durably, BEFORE launching, so a
|
|
136
|
-
// pre-session_start crash-retry (crtrd.ts) can tell "was the attempt I'm
|
|
137
|
-
// retrying a cycle" apart from an ordinary resume/crash. Cleared by
|
|
138
|
-
// canvas-stophook's session_start handler the instant this boot is confirmed.
|
|
139
|
-
// Written unconditionally (true for cycling, false otherwise) so it never
|
|
140
|
-
// goes stale across launch kinds.
|
|
141
|
-
updateNode(nodeId, { cycle_pending: cycling });
|
|
142
|
-
meta.cycle_pending = cycling;
|
|
143
|
-
// A truly fresh launch — no `.jsonl` to resume OR cycle from — starts a NEW
|
|
144
|
-
// pi session, so the old session identity is dead. Clear it from meta NOW,
|
|
145
|
-
// before launch, so the recorded state matches what we're about to do. This
|
|
146
|
-
// is load-bearing for boot-failure classification: if this fresh broker dies
|
|
147
|
-
// BEFORE session_start (recording no new pid and no new session), the
|
|
148
|
-
// daemon's pid-null branch keys on pi_session_id — a leftover stale id would
|
|
149
|
-
// be misread as a "stranded relaunch" and retried forever instead of surfaced
|
|
150
|
-
// via transition('crash') + surfaceBootFailure. Clearing it makes the failure
|
|
151
|
-
// show as pid=NULL + session=NULL → boot failure, the correct terminal
|
|
152
|
-
// outcome. A cycling refresh keeps BOTH ids — the `.jsonl` and its recorded
|
|
153
|
-
// session id are exactly what's being reused — so a cycling broker that dies
|
|
154
|
-
// pre-session_start reads as a "stranded relaunch" and is retried, same class
|
|
155
|
-
// as a resume revive today.
|
|
156
|
-
if (!hasSessionPath && (meta.pi_session_id != null || meta.pi_session_file != null)) {
|
|
157
|
-
updateNode(nodeId, { pi_session_id: null, pi_session_file: null });
|
|
158
|
-
meta.pi_session_id = null;
|
|
159
|
-
meta.pi_session_file = null;
|
|
160
|
-
}
|
|
161
|
-
// A true resume needs no kickoff — the saved conversation just continues. A
|
|
162
|
-
// fresh revive OR a cycling refresh-yield both get one so the (re-rooted)
|
|
163
|
-
// window re-reads its roadmap and continues: drainBearings is the one-shot
|
|
164
|
-
// consuming step (yield note + feed cursor + persona ack); the builder is
|
|
165
|
-
// then pure. `resume` carries `resumeSessionPath`/`newCycle` through
|
|
166
|
-
// unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
|
|
167
|
-
// the right argv either way.
|
|
168
|
-
let inv;
|
|
169
|
-
if (resuming) {
|
|
170
|
-
inv = buildPiArgv(meta, resume);
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
// Fresh or cycling: drain the one-shot bearings, then build the kickoff.
|
|
174
|
-
// When a scheduled bare self-alarm drove this revive, opts.wakeReason
|
|
175
|
-
// carries the wake provenance so the kickoff leads with a <crtr-wake> block
|
|
176
|
-
// ("a timer woke you"); every other reviveNode caller passes nothing → no
|
|
177
|
-
// block.
|
|
178
|
-
const bearings = drainBearings(meta);
|
|
179
|
-
inv = buildPiArgv(meta, {
|
|
180
|
-
...resume,
|
|
181
|
-
prompt: buildReviveKickoff(meta, bearings, opts.wakeReason),
|
|
182
|
-
});
|
|
183
|
-
// Fresh or cycling revive starts a NEW LLM context — reset the on-read doc
|
|
184
|
-
// dedup so it surfaces docs from scratch (a true resume above instead KEEPS
|
|
185
|
-
// the persisted set, continuing the same transcript).
|
|
186
|
-
clearInjectedDocs(nodeId);
|
|
187
|
-
}
|
|
188
|
-
// The broker host launches the detached engine. reviveNode keeps
|
|
189
|
-
// transition+clearPid around it (the crash-safety ordering, unchanged). It
|
|
190
|
-
// opens NO viewer — engine-only; existing viewers reconnect, and a viewer-less
|
|
191
|
-
// node is brought on screen by the next `focus`.
|
|
192
|
-
transition(nodeId, 'revive');
|
|
193
|
-
// Clear any active fault the instant this node is (re)launched — don't wait
|
|
194
|
-
// for the fresh agent_start. A revive (auto or on-demand) is the recovery
|
|
195
|
-
// this marker advertised.
|
|
196
|
-
clearFault(nodeId);
|
|
197
|
-
// Cancel-on-wake (design §6.4, AC-E1): every revive-for-any-reason (an inbox
|
|
198
|
-
// event, a different wake, a manual focus) drops this node's pending deadline,
|
|
199
|
-
// so the deadline always belongs to the dormancy being left. Writes only the
|
|
200
|
-
// wakeups table, after the atomic transition above.
|
|
201
|
-
cancelDeadlinesFor(nodeId);
|
|
202
|
-
let launched;
|
|
92
|
+
// Cross-invocation revive lock (the duplicate-broker leak this fixes — live
|
|
93
|
+
// incident: repeated revive attempts against one node under socket thrash
|
|
94
|
+
// left MULTIPLE live `broker-cli` processes for the same node id, with
|
|
95
|
+
// canvas `pi_pid` overwritten and the older brokers untracked/leaked). The
|
|
96
|
+
// guard below reads `pi_pid` liveness and then, several statements later,
|
|
97
|
+
// launches + records a NEW pid — a classic check-then-act race. Two
|
|
98
|
+
// `reviveNode` calls for the SAME node (typically two separate OS processes:
|
|
99
|
+
// a retried `crtr node lifecycle revive`/web-relay bridge call racing the
|
|
100
|
+
// daemon's own auto-revive tick, or a burst of reconnect retries) can both
|
|
101
|
+
// read the SAME stale not-alive pid before either one's `recordPid` commits,
|
|
102
|
+
// both launch a broker, and the second `recordPid` silently overwrites the
|
|
103
|
+
// first's pid — leaking the first broker as an untracked live process
|
|
104
|
+
// forever. A `BEGIN IMMEDIATE` write transaction around the guard-check
|
|
105
|
+
// through `recordPid` serializes every `reviveNode` call sharing this
|
|
106
|
+
// canvas.db (same process or a concurrent one — SQLite's write lock plus the
|
|
107
|
+
// WAL/`busy_timeout` already set on every connection, see db.ts): whichever
|
|
108
|
+
// call wins the lock finishes its guard check, launch, and `recordPid`
|
|
109
|
+
// BEFORE the next contender's guard check ever runs, so a second contender
|
|
110
|
+
// always sees the freshly-recorded ALIVE pid and no-ops instead of
|
|
111
|
+
// double-launching. Committed UNCONDITIONALLY in `finally` — never rolled
|
|
112
|
+
// back — because every write inside (the cycle bump, the crash-path
|
|
113
|
+
// `transition('crash')`/`clearPid` on a launch failure, etc.) must persist
|
|
114
|
+
// exactly as it would with no transaction at all (each statement
|
|
115
|
+
// auto-committing today); this adds only the missing mutual exclusion, it
|
|
116
|
+
// changes no write's durability or the function's existing throw/return
|
|
117
|
+
// semantics.
|
|
118
|
+
const db = openDb();
|
|
119
|
+
db.exec('BEGIN IMMEDIATE');
|
|
203
120
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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`).
|
|
147
|
+
const live = getNode(nodeId) ?? meta;
|
|
148
|
+
if (isRecordedPidAlive(live.pi_pid, live.pi_pid_identity)) {
|
|
149
|
+
return {
|
|
150
|
+
window: null,
|
|
151
|
+
session: live.tmux_session ?? null,
|
|
152
|
+
resumed: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// Every (re)launch is a new cycle — bump the counter so CRTR_CYCLES and the
|
|
156
|
+
// footer stay in sync. Mutate the in-memory meta too so buildPiArgv below
|
|
157
|
+
// launches with the incremented count.
|
|
158
|
+
meta.cycles = (meta.cycles ?? 0) + 1;
|
|
159
|
+
updateNode(nodeId, { cycles: meta.cycles });
|
|
160
|
+
// Decide whether to wake the saved pi conversation, cycle it in place (a
|
|
161
|
+
// refresh-yield reusing the same `.jsonl`), or start fresh. resumeArgs
|
|
162
|
+
// resumes/cycles ONLY by the absolute session-file path (cwd-immune) and ONLY
|
|
163
|
+
// when that .jsonl actually exists; there is no bare-id fallback (the broker
|
|
164
|
+
// preflight rejects a bare `--session`). No file on disk yet ⇒ a fresh launch.
|
|
165
|
+
const resume = resumeArgs(meta, opts.resume);
|
|
166
|
+
// hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
|
|
167
|
+
// as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
|
|
168
|
+
// the latter case). cycling and resuming are mutually exclusive; `resuming`
|
|
169
|
+
// keeps the STRICT true-resume-only meaning `ReviveResult.resumed` and the
|
|
170
|
+
// launch-placement hint below have always carried.
|
|
171
|
+
const hasSessionPath = resume.resumeSessionPath !== undefined;
|
|
172
|
+
const cycling = hasSessionPath && resume.newCycle === true;
|
|
173
|
+
const resuming = hasSessionPath && !cycling;
|
|
174
|
+
// Mirror THIS launch attempt's cycling-ness durably, BEFORE launching, so a
|
|
175
|
+
// pre-session_start crash-retry (crtrd.ts) can tell "was the attempt I'm
|
|
176
|
+
// retrying a cycle" apart from an ordinary resume/crash. Cleared by
|
|
177
|
+
// canvas-stophook's session_start handler the instant this boot is confirmed.
|
|
178
|
+
// Written unconditionally (true for cycling, false otherwise) so it never
|
|
179
|
+
// goes stale across launch kinds.
|
|
180
|
+
updateNode(nodeId, { cycle_pending: cycling });
|
|
181
|
+
meta.cycle_pending = cycling;
|
|
182
|
+
// A truly fresh launch — no `.jsonl` to resume OR cycle from — starts a NEW
|
|
183
|
+
// pi session, so the old session identity is dead. Clear it from meta NOW,
|
|
184
|
+
// before launch, so the recorded state matches what we're about to do. This
|
|
185
|
+
// is load-bearing for boot-failure classification: if this fresh broker dies
|
|
186
|
+
// BEFORE session_start (recording no new pid and no new session), the
|
|
187
|
+
// daemon's pid-null branch keys on pi_session_id — a leftover stale id would
|
|
188
|
+
// be misread as a "stranded relaunch" and retried forever instead of surfaced
|
|
189
|
+
// via transition('crash') + surfaceBootFailure. Clearing it makes the failure
|
|
190
|
+
// show as pid=NULL + session=NULL → boot failure, the correct terminal
|
|
191
|
+
// outcome. A cycling refresh keeps BOTH ids — the `.jsonl` and its recorded
|
|
192
|
+
// session id are exactly what's being reused — so a cycling broker that dies
|
|
193
|
+
// pre-session_start reads as a "stranded relaunch" and is retried, same class
|
|
194
|
+
// as a resume revive today.
|
|
195
|
+
if (!hasSessionPath && (meta.pi_session_id != null || meta.pi_session_file != null)) {
|
|
196
|
+
updateNode(nodeId, { pi_session_id: null, pi_session_file: null });
|
|
197
|
+
meta.pi_session_id = null;
|
|
198
|
+
meta.pi_session_file = null;
|
|
199
|
+
}
|
|
200
|
+
// A true resume needs no kickoff — the saved conversation just continues. A
|
|
201
|
+
// fresh revive OR a cycling refresh-yield both get one so the (re-rooted)
|
|
202
|
+
// window re-reads its roadmap and continues: drainBearings is the one-shot
|
|
203
|
+
// consuming step (yield note + feed cursor + persona ack); the builder is
|
|
204
|
+
// then pure. `resume` carries `resumeSessionPath`/`newCycle` through
|
|
205
|
+
// unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
|
|
206
|
+
// the right argv either way.
|
|
207
|
+
let inv;
|
|
208
|
+
if (resuming) {
|
|
209
|
+
inv = buildPiArgv(meta, resume);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
// Fresh or cycling: drain the one-shot bearings, then build the kickoff.
|
|
213
|
+
// When a scheduled bare self-alarm drove this revive, opts.wakeReason
|
|
214
|
+
// carries the wake provenance so the kickoff leads with a <crtr-wake> block
|
|
215
|
+
// ("a timer woke you"); every other reviveNode caller passes nothing → no
|
|
216
|
+
// block.
|
|
217
|
+
const bearings = drainBearings(meta);
|
|
218
|
+
inv = buildPiArgv(meta, {
|
|
219
|
+
...resume,
|
|
220
|
+
prompt: buildReviveKickoff(meta, bearings, opts.wakeReason),
|
|
221
|
+
});
|
|
222
|
+
// Fresh or cycling revive starts a NEW LLM context — reset the on-read doc
|
|
223
|
+
// dedup so it surfaces docs from scratch (a true resume above instead KEEPS
|
|
224
|
+
// the persisted set, continuing the same transcript).
|
|
225
|
+
clearInjectedDocs(nodeId);
|
|
226
|
+
}
|
|
227
|
+
// The broker host launches the detached engine. reviveNode keeps
|
|
228
|
+
// transition+clearPid around it (the crash-safety ordering, unchanged). It
|
|
229
|
+
// opens NO viewer — engine-only; existing viewers reconnect, and a viewer-less
|
|
230
|
+
// node is brought on screen by the next `focus`.
|
|
231
|
+
transition(nodeId, 'revive');
|
|
232
|
+
// Clear any active fault the instant this node is (re)launched — don't wait
|
|
233
|
+
// for the fresh agent_start. A revive (auto or on-demand) is the recovery
|
|
234
|
+
// this marker advertised.
|
|
235
|
+
clearFault(nodeId);
|
|
236
|
+
// Cancel-on-wake (design §6.4, AC-E1): every revive-for-any-reason (an inbox
|
|
237
|
+
// event, a different wake, a manual focus) drops this node's pending deadline,
|
|
238
|
+
// so the deadline always belongs to the dormancy being left. Writes only the
|
|
239
|
+
// wakeups table, after the atomic transition above.
|
|
240
|
+
cancelDeadlinesFor(nodeId);
|
|
241
|
+
let launched;
|
|
211
242
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// never told it will not come back on its own. Mirrors closeNode's step-4
|
|
218
|
-
// fan-out (same subscribers table, same active/passive split).
|
|
219
|
-
fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead: ${error.message}\n\n` +
|
|
220
|
-
`It stays dead until you revive it — \`crtr node lifecycle revive ${nodeId}\`.`, { reason: 'child-crashed', child: nodeId });
|
|
243
|
+
launched = headlessBrokerHost.launch(nodeId, inv, {
|
|
244
|
+
cwd: meta.cwd,
|
|
245
|
+
name: fullName(meta),
|
|
246
|
+
resuming,
|
|
247
|
+
});
|
|
221
248
|
}
|
|
222
|
-
catch {
|
|
223
|
-
|
|
249
|
+
catch (error) {
|
|
250
|
+
try {
|
|
251
|
+
transition(nodeId, 'crash');
|
|
252
|
+
// Doctrine wake: a launch-refusal here marks the node dead with NOTHING
|
|
253
|
+
// else fanning that out — the daemon's own auto-revive is the most common
|
|
254
|
+
// caller (superviseTick, best-effort try/catch, stderr-only on failure), so
|
|
255
|
+
// without this a manager waiting dormant on this child hangs forever,
|
|
256
|
+
// never told it will not come back on its own. Mirrors closeNode's step-4
|
|
257
|
+
// fan-out (same subscribers table, same active/passive split).
|
|
258
|
+
fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead: ${error.message}\n\n` +
|
|
259
|
+
`It stays dead until you revive it — \`crtr node lifecycle revive ${nodeId}\`.`, { reason: 'child-crashed', child: nodeId });
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
/* best-effort cleanup */
|
|
263
|
+
}
|
|
264
|
+
clearPid(nodeId);
|
|
265
|
+
throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
|
|
224
266
|
}
|
|
225
|
-
|
|
226
|
-
|
|
267
|
+
// Record the real broker pid immediately, still inside the lock — this is
|
|
268
|
+
// the write that makes the guard's next reader (a contender unblocked from
|
|
269
|
+
// its own BEGIN IMMEDIATE the instant this transaction commits) see an
|
|
270
|
+
// ALIVE pid instead of racing past the same stale one this call just
|
|
271
|
+
// relaunched from. session_start will re-record the same pid after
|
|
272
|
+
// extension bind, but the daemon must supervise the process during the
|
|
273
|
+
// cold-boot window too; otherwise a slow-but-alive broker reads as
|
|
274
|
+
// pid=null+session=null and can be falsely crashed by the boot-grace path.
|
|
275
|
+
if (launched.pid !== null)
|
|
276
|
+
recordPid(nodeId, launched.pid);
|
|
277
|
+
return { window: null, session: meta.tmux_session ?? null, resumed: resuming, launch: launched };
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
db.exec('COMMIT');
|
|
227
281
|
}
|
|
228
|
-
// Record the real broker pid immediately. session_start will re-record the
|
|
229
|
-
// same pid after extension bind, but the daemon must supervise the process
|
|
230
|
-
// during the cold-boot window too; otherwise a slow-but-alive broker reads as
|
|
231
|
-
// pid=null+session=null and can be falsely crashed by the boot-grace path.
|
|
232
|
-
if (launched.pid !== null)
|
|
233
|
-
recordPid(nodeId, launched.pid);
|
|
234
|
-
return { window: null, session: meta.tmux_session ?? null, resumed: resuming, launch: launched };
|
|
235
282
|
}
|
|
@@ -67,7 +67,10 @@ function ensureBracketedPaste() {
|
|
|
67
67
|
const current = tmux(['show-options', '-s', 'terminal-overrides']).stdout;
|
|
68
68
|
if (current.includes('Enbp='))
|
|
69
69
|
return;
|
|
70
|
-
tmux
|
|
70
|
+
// Emit a LITERAL backslash-E (`\\E`) so tmux's option parser resolves it to ESC.
|
|
71
|
+
// A single `\E` in this JS source collapses to the letter `E`, which stores a
|
|
72
|
+
// broken `Enbp=E[?2004h` (no ESC) and silently disables bracketed paste.
|
|
73
|
+
tmux(['set-option', '-sga', 'terminal-overrides', '*:Enbp=\\E[?2004h:Dsbp=\\E[?2004l']);
|
|
71
74
|
}
|
|
72
75
|
/** Create a detached session rooted at `cwd` if it doesn't exist. The session
|
|
73
76
|
* name is a root's tmux home; every node under that root is a window in it. */
|
|
@@ -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
|
});
|
|
@@ -38,9 +38,18 @@ function sendExec(req, baseCwd) {
|
|
|
38
38
|
resolve({ ok: true, exitCode: code, stdout: stdout ?? '', stderr: stderr ?? '' });
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const signal = err.signal;
|
|
42
|
+
if (signal) {
|
|
43
|
+
// Killed by signal: it ran; surface as a non-zero exit. A SIGKILL with
|
|
44
|
+
// no stderr at all (the live-incident shape: a guest OOM killer takes
|
|
45
|
+
// out the child before it ever writes anything) used to come back as
|
|
46
|
+
// a bare `failed (1)` with nothing to explain it — the signal itself
|
|
47
|
+
// IS the useful diagnostic here, so surface it when stderr is empty
|
|
48
|
+
// rather than discarding it.
|
|
49
|
+
const detail = stderr && stderr.trim() !== ''
|
|
50
|
+
? stderr
|
|
51
|
+
: `killed by signal ${signal}${signal === 'SIGKILL' ? ' (often out-of-memory)' : ''}`;
|
|
52
|
+
resolve({ ok: true, exitCode: 1, stdout: stdout ?? '', stderr: detail });
|
|
44
53
|
return;
|
|
45
54
|
}
|
|
46
55
|
// Spawn-level failure (ENOENT/EACCES/maxBuffer): transport failure.
|
|
@@ -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
|
}
|