@crouton-kit/crouter 0.3.67 → 0.3.68

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.
@@ -23,7 +23,7 @@
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';
@@ -89,147 +89,184 @@ 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
- // Double-revive guard: identity-aware liveness on pi_pid, requiring
93
- // CONFIRMED alive not the fail-open-to-alive boolean adapter. A node
94
- // whose broker pid is CONFIRMED still running as that same broker (not a
95
- // reused pid) was already revived by another path — re-launching would put
96
- // a SECOND broker on the same session file. No-op. A bare isPidAlive would
97
- // misread a reused pid (heavy forking, esp. Linux) as "still alive" and
98
- // skip the relaunch forever; conversely, treating an INDETERMINATE read
99
- // (can't confirm either way, e.g. a transient `ps` probe failure) as
100
- // "alive" here would risk the opposite failure silently skipping a
101
- // needed relaunch and stranding the node with no engine. So this guard
102
- // blocks ONLY on a positively confirmed-alive read; `dead` AND
103
- // `indeterminate` both proceed to relaunch. We accept that tradeoff: an
104
- // `indeterminate` read CAN come from a genuinely live broker whose identity
105
- // `ps` probe transiently failed, so proceeding risks a rare double-launch —
106
- // but stranding a truly-dead node with no engine is the worse failure, and
107
- // recovering from it is far harder, so we bias toward relaunching.
108
- const live = getNode(nodeId) ?? meta;
109
- if (recordedPidLiveness(live.pi_pid, live.pi_pid_identity) === 'alive') {
110
- return {
111
- window: null,
112
- session: live.tmux_session ?? null,
113
- resumed: false,
114
- };
115
- }
116
- // Every (re)launch is a new cycle — bump the counter so CRTR_CYCLES and the
117
- // footer stay in sync. Mutate the in-memory meta too so buildPiArgv below
118
- // launches with the incremented count.
119
- meta.cycles = (meta.cycles ?? 0) + 1;
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
- launched = headlessBrokerHost.launch(nodeId, inv, {
205
- cwd: meta.cwd,
206
- name: fullName(meta),
207
- resuming,
208
- });
209
- }
210
- catch (error) {
121
+ // Double-revive guard: identity-aware liveness on pi_pid, requiring
122
+ // CONFIRMED alive — not the fail-open-to-alive boolean adapter. A node
123
+ // whose broker pid is CONFIRMED still running as that same broker (not a
124
+ // reused pid) was already revived by another path — re-launching would put
125
+ // a SECOND broker on the same session file. No-op. A bare isPidAlive would
126
+ // misread a reused pid (heavy forking, esp. Linux) as "still alive" and
127
+ // skip the relaunch forever; conversely, treating an INDETERMINATE read
128
+ // (can't confirm either way, e.g. a transient `ps` probe failure) as
129
+ // "alive" here would risk the opposite failure — silently skipping a
130
+ // needed relaunch and stranding the node with no engine. So this guard
131
+ // blocks ONLY on a positively confirmed-alive read; `dead` AND
132
+ // `indeterminate` both proceed to relaunch. We accept that tradeoff: an
133
+ // `indeterminate` read CAN come from a genuinely live broker whose identity
134
+ // `ps` probe transiently failed, so proceeding risks a rare double-launch —
135
+ // but stranding a truly-dead node with no engine is the worse failure, and
136
+ // recovering from it is far harder, so we bias toward relaunching.
137
+ const live = getNode(nodeId) ?? meta;
138
+ if (recordedPidLiveness(live.pi_pid, live.pi_pid_identity) === 'alive') {
139
+ return {
140
+ window: null,
141
+ session: live.tmux_session ?? null,
142
+ resumed: false,
143
+ };
144
+ }
145
+ // Every (re)launch is a new cycle — bump the counter so CRTR_CYCLES and the
146
+ // footer stay in sync. Mutate the in-memory meta too so buildPiArgv below
147
+ // launches with the incremented count.
148
+ meta.cycles = (meta.cycles ?? 0) + 1;
149
+ updateNode(nodeId, { cycles: meta.cycles });
150
+ // Decide whether to wake the saved pi conversation, cycle it in place (a
151
+ // refresh-yield reusing the same `.jsonl`), or start fresh. resumeArgs
152
+ // resumes/cycles ONLY by the absolute session-file path (cwd-immune) and ONLY
153
+ // when that .jsonl actually exists; there is no bare-id fallback (the broker
154
+ // preflight rejects a bare `--session`). No file on disk yet ⇒ a fresh launch.
155
+ const resume = resumeArgs(meta, opts.resume);
156
+ // hasSessionPath: there is a real `.jsonl` to hand pi via `--session`, whether
157
+ // as a true resume OR a cycling refresh-yield (resumeArgs sets `newCycle` in
158
+ // the latter case). cycling and resuming are mutually exclusive; `resuming`
159
+ // keeps the STRICT true-resume-only meaning `ReviveResult.resumed` and the
160
+ // launch-placement hint below have always carried.
161
+ const hasSessionPath = resume.resumeSessionPath !== undefined;
162
+ const cycling = hasSessionPath && resume.newCycle === true;
163
+ const resuming = hasSessionPath && !cycling;
164
+ // Mirror THIS launch attempt's cycling-ness durably, BEFORE launching, so a
165
+ // pre-session_start crash-retry (crtrd.ts) can tell "was the attempt I'm
166
+ // retrying a cycle" apart from an ordinary resume/crash. Cleared by
167
+ // canvas-stophook's session_start handler the instant this boot is confirmed.
168
+ // Written unconditionally (true for cycling, false otherwise) so it never
169
+ // goes stale across launch kinds.
170
+ updateNode(nodeId, { cycle_pending: cycling });
171
+ meta.cycle_pending = cycling;
172
+ // A truly fresh launch — no `.jsonl` to resume OR cycle from — starts a NEW
173
+ // pi session, so the old session identity is dead. Clear it from meta NOW,
174
+ // before launch, so the recorded state matches what we're about to do. This
175
+ // is load-bearing for boot-failure classification: if this fresh broker dies
176
+ // BEFORE session_start (recording no new pid and no new session), the
177
+ // daemon's pid-null branch keys on pi_session_id — a leftover stale id would
178
+ // be misread as a "stranded relaunch" and retried forever instead of surfaced
179
+ // via transition('crash') + surfaceBootFailure. Clearing it makes the failure
180
+ // show as pid=NULL + session=NULL → boot failure, the correct terminal
181
+ // outcome. A cycling refresh keeps BOTH ids — the `.jsonl` and its recorded
182
+ // session id are exactly what's being reused — so a cycling broker that dies
183
+ // pre-session_start reads as a "stranded relaunch" and is retried, same class
184
+ // as a resume revive today.
185
+ if (!hasSessionPath && (meta.pi_session_id != null || meta.pi_session_file != null)) {
186
+ updateNode(nodeId, { pi_session_id: null, pi_session_file: null });
187
+ meta.pi_session_id = null;
188
+ meta.pi_session_file = null;
189
+ }
190
+ // A true resume needs no kickoff — the saved conversation just continues. A
191
+ // fresh revive OR a cycling refresh-yield both get one so the (re-rooted)
192
+ // window re-reads its roadmap and continues: drainBearings is the one-shot
193
+ // consuming step (yield note + feed cursor + persona ack); the builder is
194
+ // then pure. `resume` carries `resumeSessionPath`/`newCycle` through
195
+ // unchanged (empty for true-fresh, both set for cycling) so buildPiArgv emits
196
+ // the right argv either way.
197
+ let inv;
198
+ if (resuming) {
199
+ inv = buildPiArgv(meta, resume);
200
+ }
201
+ else {
202
+ // Fresh or cycling: drain the one-shot bearings, then build the kickoff.
203
+ // When a scheduled bare self-alarm drove this revive, opts.wakeReason
204
+ // carries the wake provenance so the kickoff leads with a <crtr-wake> block
205
+ // ("a timer woke you"); every other reviveNode caller passes nothing → no
206
+ // block.
207
+ const bearings = drainBearings(meta);
208
+ inv = buildPiArgv(meta, {
209
+ ...resume,
210
+ prompt: buildReviveKickoff(meta, bearings, opts.wakeReason),
211
+ });
212
+ // Fresh or cycling revive starts a NEW LLM context — reset the on-read doc
213
+ // dedup so it surfaces docs from scratch (a true resume above instead KEEPS
214
+ // the persisted set, continuing the same transcript).
215
+ clearInjectedDocs(nodeId);
216
+ }
217
+ // The broker host launches the detached engine. reviveNode keeps
218
+ // transition+clearPid around it (the crash-safety ordering, unchanged). It
219
+ // opens NO viewer — engine-only; existing viewers reconnect, and a viewer-less
220
+ // node is brought on screen by the next `focus`.
221
+ transition(nodeId, 'revive');
222
+ // Clear any active fault the instant this node is (re)launched — don't wait
223
+ // for the fresh agent_start. A revive (auto or on-demand) is the recovery
224
+ // this marker advertised.
225
+ clearFault(nodeId);
226
+ // Cancel-on-wake (design §6.4, AC-E1): every revive-for-any-reason (an inbox
227
+ // event, a different wake, a manual focus) drops this node's pending deadline,
228
+ // so the deadline always belongs to the dormancy being left. Writes only the
229
+ // wakeups table, after the atomic transition above.
230
+ cancelDeadlinesFor(nodeId);
231
+ let launched;
211
232
  try {
212
- transition(nodeId, 'crash');
213
- // Doctrine wake: a launch-refusal here marks the node dead with NOTHING
214
- // else fanning that out — the daemon's own auto-revive is the most common
215
- // caller (superviseTick, best-effort try/catch, stderr-only on failure), so
216
- // without this a manager waiting dormant on this child hangs forever,
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 });
233
+ launched = headlessBrokerHost.launch(nodeId, inv, {
234
+ cwd: meta.cwd,
235
+ name: fullName(meta),
236
+ resuming,
237
+ });
221
238
  }
222
- catch {
223
- /* best-effort cleanup */
239
+ catch (error) {
240
+ try {
241
+ transition(nodeId, 'crash');
242
+ // Doctrine wake: a launch-refusal here marks the node dead with NOTHING
243
+ // else fanning that out — the daemon's own auto-revive is the most common
244
+ // caller (superviseTick, best-effort try/catch, stderr-only on failure), so
245
+ // without this a manager waiting dormant on this child hangs forever,
246
+ // never told it will not come back on its own. Mirrors closeNode's step-4
247
+ // fan-out (same subscribers table, same active/passive split).
248
+ fanDoctrineWake(nodeId, subscribersOf(nodeId), `Child crashed — ${fullName(meta)} (${nodeId}) failed to relaunch and is now dead: ${error.message}\n\n` +
249
+ `It stays dead until you revive it — \`crtr node lifecycle revive ${nodeId}\`.`, { reason: 'child-crashed', child: nodeId });
250
+ }
251
+ catch {
252
+ /* best-effort cleanup */
253
+ }
254
+ clearPid(nodeId);
255
+ throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
224
256
  }
225
- clearPid(nodeId);
226
- throw new Error(`[broker] refusing to revive ${nodeId}: ${error.message}`);
257
+ // Record the real broker pid immediately, still inside the lock — this is
258
+ // the write that makes the guard's next reader (a contender unblocked from
259
+ // its own BEGIN IMMEDIATE the instant this transaction commits) see an
260
+ // ALIVE pid instead of racing past the same stale one this call just
261
+ // relaunched from. session_start will re-record the same pid after
262
+ // extension bind, but the daemon must supervise the process during the
263
+ // cold-boot window too; otherwise a slow-but-alive broker reads as
264
+ // pid=null+session=null and can be falsely crashed by the boot-grace path.
265
+ if (launched.pid !== null)
266
+ recordPid(nodeId, launched.pid);
267
+ return { window: null, session: meta.tmux_session ?? null, resumed: resuming, launch: launched };
268
+ }
269
+ finally {
270
+ db.exec('COMMIT');
227
271
  }
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
272
  }
@@ -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(['set-option', '-sga', 'terminal-overrides', '*:Enbp=\E[?2004h:Dsbp=\E[?2004l']);
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. */
@@ -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
- if (err.signal) {
42
- // Killed by signal: it ran; surface as a non-zero exit.
43
- resolve({ ok: true, exitCode: 1, stdout: stdout ?? '', stderr: stderr ?? '' });
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.