@crouton-kit/crouter 0.3.35 → 0.3.37

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.
Files changed (71) hide show
  1. package/dist/build-root.js +2 -6
  2. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  3. package/dist/builtin-personas/runtime-base.md +1 -1
  4. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
  5. package/dist/builtin-views/canvas/core.mjs +3 -2
  6. package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
  7. package/dist/clients/attach/attach-cmd.js +383 -386
  8. package/dist/clients/web/web-cmd.js +7 -7
  9. package/dist/commands/__tests__/human.test.js +1 -15
  10. package/dist/commands/attention.js +9 -6
  11. package/dist/commands/canvas-history/search.js +2 -2
  12. package/dist/commands/canvas-history/show.js +3 -3
  13. package/dist/commands/canvas-history.js +1 -1
  14. package/dist/commands/canvas-issue.js +1 -0
  15. package/dist/commands/canvas-snapshot.js +1 -0
  16. package/dist/commands/canvas.js +10 -11
  17. package/dist/commands/chord.js +1 -0
  18. package/dist/commands/human/prompts.js +0 -1
  19. package/dist/commands/human/queue.js +1 -0
  20. package/dist/commands/node-snapshot.js +1 -0
  21. package/dist/commands/node.js +12 -11
  22. package/dist/commands/push.d.ts +0 -1
  23. package/dist/commands/push.js +2 -228
  24. package/dist/commands/revive.js +2 -2
  25. package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
  26. package/dist/commands/surface.js +3 -2
  27. package/dist/commands/sys/daemon.d.ts +2 -0
  28. package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
  29. package/dist/commands/sys/doctor.js +1 -0
  30. package/dist/commands/sys.js +4 -3
  31. package/dist/core/__tests__/daemon-boot.test.js +1 -1
  32. package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
  33. package/dist/core/__tests__/daemon-wedge.test.js +87 -0
  34. package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
  35. package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
  36. package/dist/core/__tests__/history-inbox.test.js +105 -0
  37. package/dist/core/__tests__/kickoff.test.js +3 -3
  38. package/dist/core/__tests__/revive.test.js +39 -1
  39. package/dist/core/canvas/history.d.ts +1 -1
  40. package/dist/core/canvas/history.js +90 -3
  41. package/dist/core/canvas/paths.d.ts +6 -0
  42. package/dist/core/canvas/paths.js +9 -0
  43. package/dist/core/canvas/status-glyph.js +1 -0
  44. package/dist/core/fault-classifier.d.ts +2 -2
  45. package/dist/core/feed/inbox.d.ts +7 -12
  46. package/dist/core/feed/inbox.js +45 -29
  47. package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
  48. package/dist/core/preview-registry.js +14 -112
  49. package/dist/core/render.js +1 -1
  50. package/dist/core/runtime/busy.d.ts +15 -0
  51. package/dist/core/runtime/busy.js +32 -1
  52. package/dist/core/runtime/close.d.ts +10 -0
  53. package/dist/core/runtime/close.js +18 -13
  54. package/dist/core/runtime/fault-recovery.js +4 -0
  55. package/dist/core/runtime/kickoff.js +5 -7
  56. package/dist/core/runtime/placement.js +1 -1
  57. package/dist/core/runtime/revive.js +9 -1
  58. package/dist/core/runtime/tmux.d.ts +2 -2
  59. package/dist/core/runtime/tmux.js +2 -2
  60. package/dist/daemon/crtrd-cli.js +1 -1
  61. package/dist/daemon/crtrd.d.ts +24 -0
  62. package/dist/daemon/crtrd.js +213 -5
  63. package/dist/daemon/manage.js +2 -2
  64. package/dist/pi-extensions/canvas-nav.js +2 -2
  65. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  66. package/dist/pi-extensions/canvas-stophook.js +59 -2
  67. package/dist/web-client/assets/{index-IAJVtuVe.js → index-MSKSWwV0.js} +1 -1
  68. package/dist/web-client/index.html +1 -1
  69. package/package.json +1 -1
  70. package/dist/commands/daemon.d.ts +0 -2
  71. /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
@@ -49,11 +49,14 @@
49
49
  import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, statSync, } from 'node:fs';
50
50
  import { dirname, join } from 'node:path';
51
51
  import { homedir } from 'node:os';
52
+ import { spawnSync } from 'node:child_process';
52
53
  import { crtrHome, nodeDir, jobDir } from '../core/canvas/paths.js';
53
- import { listNodes, getRow, getNode, dueClockTriggers, consumeTrigger, advanceTrigger, listFocuses, closeFocusRow, pruneToLimit, } from '../core/canvas/index.js';
54
+ import { listNodes, getRow, getNode, dueClockTriggers, consumeTrigger, advanceTrigger, listFocuses, closeFocusRow, pruneToLimit, subscribersOf, } from '../core/canvas/index.js';
55
+ import { fullName } from '../core/canvas/labels.js';
54
56
  import { transition } from '../core/runtime/lifecycle.js';
55
- import { isBusy } from '../core/runtime/busy.js';
56
- import { FAULT_QUIET_MS } from '../core/runtime/fault.js';
57
+ import { isBusy, busySince } from '../core/runtime/busy.js';
58
+ import { FAULT_QUIET_MS, recordFault } from '../core/runtime/fault.js';
59
+ import { fanDoctrineWake } from '../core/runtime/close.js';
57
60
  import { logger } from '../core/log.js';
58
61
  export { FAULT_QUIET_MS };
59
62
  import { isPidAlive } from '../core/canvas/pid.js';
@@ -209,6 +212,207 @@ function handleYieldStall(row, pid, now) {
209
212
  }
210
213
  }
211
214
  // ---------------------------------------------------------------------------
215
+ // Wedge detection (issue #110): a broker whose pid is alive but whose engine is
216
+ // frozen MID-TURN (a runaway subprocess, a hung stream) is invisible to every
217
+ // other guarantee — the pid-alive branch above returns early, yield-stall only
218
+ // fires on intent='refresh', and provider-fault recovery needs a completed
219
+ // ERROR turn. A node in this state never pushes, so its parent waits forever
220
+ // with no wake and no visible fault marker (`node inspect list --hanging`
221
+ // returns nothing — there's no fault recorded, just silence).
222
+ //
223
+ // Detection, not recovery: this NOTIFIES (fans a doctrine wake + records a
224
+ // `daemon→node`/'wedged' fault so the node shows ⌘ “wedged · needs you” on the
225
+ // canvas graph views, same surface `node inspect list --hanging` already reads)
226
+ // — it never kills or restarts the engine. Killing the runaway SUBPROCESS (not
227
+ // the node) is the sanctioned recovery; see `crtr memory read
228
+ // wedged-child-on-runaway-bash`.
229
+ //
230
+ // Two corroborating signals, both generous, so a routine long-running bash/
231
+ // build/test tool call is never mistaken for a wedge:
232
+ // 1. quietForMs — ms since the busy marker's mtime last moved. canvas-
233
+ // stophook re-touches that marker (`touchBusy`) on every engine-level sign
234
+ // of life within a turn: a tool starting/streaming/ending, a streamed
235
+ // assistant token, or the turn ending. A tool that is actively streaming
236
+ // output, or a turn producing tokens, keeps this at ~0 no matter how long
237
+ // the OVERALL turn runs. Only a turn with NO observed engine event at all
238
+ // for WEDGE_QUIET_MS is even a candidate.
239
+ // 2. cpuPercent — the broker's WHOLE PROCESS TREE (the broker pid + every
240
+ // descendant), summed, sampled ONLY once a node is already a time-based
241
+ // candidate (never every tick — the `ps` shell-out is cheap but pointless
242
+ // on a healthy node). Sampling the tree, not just the broker pid, matters
243
+ // because the broker often sits idle awaiting a CHILD subprocess (bash,
244
+ // a build, a test run) that is the one actually burning CPU — sampling
245
+ // the broker alone would read that as 0% and false-fire on a perfectly
246
+ // healthy long-running tool call. A pi process (or any descendant)
247
+ // genuinely computing shows non-trivial tree CPU and is exonerated even
248
+ // past the quiet grace; a pi merely awaiting a subprocess or a stalled
249
+ // I/O call — wedged OR legitimately blocked on something equally silent —
250
+ // reads ~0% across the whole tree, matching the filed issue's own
251
+ // evidence (pid alive, 0% CPU, session file not advancing). A failed/
252
+ // unreadable CPU sample is treated as UNKNOWN, never as “low” — the
253
+ // verdict stays 'pending' rather than risk a false fire on an unreadable
254
+ // signal.
255
+ //
256
+ // ACCEPTED residual: a legitimate subprocess blocked on silent I/O (e.g. a
257
+ // 20-minute download emitting no output, a long `sleep`) reads ~0% tree
258
+ // CPU and is indistinguishable from a real wedge at the OS level — no CPU
259
+ // signal can tell a hung child from an I/O-blocked one. This is accepted,
260
+ // not deferred, because detection only NOTIFIES and never kills: the cost
261
+ // of that rare false positive is one doctrine wake to a parent, and 20+
262
+ // minutes of total silence with near-zero tree CPU is itself worth
263
+ // surfacing either way.
264
+ // ---------------------------------------------------------------------------
265
+ // Generous: routine long tool calls settle well under this; only a turn with
266
+ // zero observed engine progress for 20+ minutes is even a wedge CANDIDATE.
267
+ export const WEDGE_QUIET_MS = 20 * 60_000;
268
+ // CPU% ceiling for the corroborating sample, taken once quietForMs already
269
+ // clears the grace above. Small but nonzero — tolerates measurement noise
270
+ // without accepting a process that's visibly still doing work.
271
+ export const WEDGE_CPU_MAX_PERCENT = 2;
272
+ /** Pure wedge decision — mirrors livenessVerdict/yieldStallVerdict: a total
273
+ * function of the observed signals, no clock or process access of its own.
274
+ * `busy` false — the turn already ended (however it routed) — is never a
275
+ * wedge, regardless of how stale the marker looks. `quietForMs` null means
276
+ * the heartbeat marker couldn't be read (treated as “not enough evidence”, not
277
+ * “wedged”). `cpuPercent` null (sample failed/unavailable) or above the
278
+ * ceiling — EITHER keeps the verdict at 'pending' (keep watching next tick)
279
+ * rather than firing on an unconfirmed or contradicted signal. */
280
+ export function wedgeVerdict(input) {
281
+ if (!input.busy)
282
+ return 'leave';
283
+ if (input.quietForMs === null || input.quietForMs < WEDGE_QUIET_MS)
284
+ return 'leave';
285
+ if (input.cpuPercent === null || input.cpuPercent > WEDGE_CPU_MAX_PERCENT)
286
+ return 'pending';
287
+ return 'wedged';
288
+ }
289
+ /** Notified-episode dedup (one notice per wedge, per §Detection-not-repeat-fire):
290
+ * keyed on node id → the busy-marker mtime we already notified for. The marker
291
+ * only advances on genuine engine progress (a heartbeat) or a fresh turn
292
+ * starting, so a stale wedge that later recovers (or a brand-new turn) always
293
+ * carries a DIFFERENT mtime and is free to notify again. In-memory only, like
294
+ * every other daemon dedup latch here — a restart re-arms it (worst case: one
295
+ * repeat notice), which is the same tradeoff `notifiedTriggerFailures` and
296
+ * `unhealthySince` already accept. */
297
+ const wedgeNotifiedAt = new Map();
298
+ /** Pure: sum CPU% across a process's entire tree (itself + every descendant).
299
+ * Parses `ps -ax -o pid=,ppid=,pcpu=` output, builds a ppid→children map, and
300
+ * DFS's from `rootPid` collecting root + all descendants; malformed/blank
301
+ * lines are skipped. Returns `null` if `rootPid` never appears in the output
302
+ * (can't measure at all — treated as UNKNOWN by wedgeVerdict, never as
303
+ * “low”) or the output has no parsable rows. Exported for direct unit testing
304
+ * — no process access of its own, mirroring wedgeVerdict's purity. */
305
+ export function sumTreeCpu(psOutput, rootPid) {
306
+ const byPid = new Map(); // pid → pcpu
307
+ const childrenOf = new Map(); // ppid → child pids
308
+ for (const line of psOutput.split('\n')) {
309
+ const trimmed = line.trim();
310
+ if (trimmed === '')
311
+ continue;
312
+ const parts = trimmed.split(/\s+/);
313
+ if (parts.length < 3)
314
+ continue;
315
+ const pid = Number(parts[0]);
316
+ const ppid = Number(parts[1]);
317
+ const pcpu = Number(parts[2]);
318
+ if (!Number.isFinite(pid) || !Number.isFinite(ppid) || !Number.isFinite(pcpu))
319
+ continue;
320
+ byPid.set(pid, pcpu);
321
+ const siblings = childrenOf.get(ppid);
322
+ if (siblings === undefined)
323
+ childrenOf.set(ppid, [pid]);
324
+ else
325
+ siblings.push(pid);
326
+ }
327
+ if (!byPid.has(rootPid))
328
+ return null;
329
+ let total = 0;
330
+ const stack = [rootPid];
331
+ const seen = new Set();
332
+ while (stack.length > 0) {
333
+ const pid = stack.pop();
334
+ if (seen.has(pid))
335
+ continue; // guard against a malformed/cyclic ps snapshot
336
+ seen.add(pid);
337
+ total += byPid.get(pid) ?? 0;
338
+ const kids = childrenOf.get(pid);
339
+ if (kids !== undefined)
340
+ stack.push(...kids);
341
+ }
342
+ return total;
343
+ }
344
+ /** Sample the broker's WHOLE PROCESS TREE — the broker pid plus every
345
+ * descendant, summed — via one `ps` shell-out, the OS-level signal that
346
+ * corroborates the heartbeat silence (see the section banner above). Null on
347
+ * ANY failure (the shell-out itself failing, `ps` missing, or `sumTreeCpu`
348
+ * finding the root pid absent/output unparseable) — wedgeVerdict treats null
349
+ * exactly like “too high”, so a broken probe can only ever suppress a false
350
+ * fire, never cause one. */
351
+ function readTreeCpuPercent(pid) {
352
+ try {
353
+ const r = spawnSync('ps', ['-ax', '-o', 'pid=,ppid=,pcpu='], { encoding: 'utf8', timeout: 2000 });
354
+ if (r.status !== 0 || typeof r.stdout !== 'string')
355
+ return null;
356
+ return sumTreeCpu(r.stdout, pid);
357
+ }
358
+ catch {
359
+ return null;
360
+ }
361
+ }
362
+ /** Enact wedge detection for a LIVE, busy node (called from the pid-alive
363
+ * branch of handleNodeLiveness, alongside handleYieldStall — the two never
364
+ * overlap in practice: yield-stall requires the turn OVER, wedge requires it
365
+ * mid-flight). Reads the heartbeat + samples CPU only when already past the
366
+ * time grace, fans one doctrine wake + records one `daemon→node` fault per
367
+ * episode, and never touches the engine itself. */
368
+ function handleWedgeDetection(id, pid, now) {
369
+ if (!isBusy(id)) {
370
+ wedgeNotifiedAt.delete(id); // turn ended (however it routed) — next turn starts a fresh episode
371
+ return;
372
+ }
373
+ const since = busySince(id);
374
+ const quietForMs = since === null ? null : now - since;
375
+ const cpuPercent = quietForMs !== null && quietForMs >= WEDGE_QUIET_MS ? readTreeCpuPercent(pid) : null;
376
+ const verdict = wedgeVerdict({ busy: true, quietForMs, cpuPercent });
377
+ if (verdict !== 'wedged')
378
+ return;
379
+ if (since !== null && wedgeNotifiedAt.get(id) === since)
380
+ return; // already notified THIS episode
381
+ if (since !== null)
382
+ wedgeNotifiedAt.set(id, since);
383
+ const meta = getNode(id);
384
+ const name = meta !== null ? fullName(meta) : id;
385
+ const minutes = Math.round((quietForMs ?? WEDGE_QUIET_MS) / 60_000);
386
+ const label = `Child wedged — ${name} (${id}) is alive and mid-turn but has shown NO engine progress for ` +
387
+ `${minutes}+ minutes and its process is near-idle. It will NOT recover on its own — read ` +
388
+ `\`crtr memory read wedged-child-on-runaway-bash\` and, if a runaway subprocess is to blame, kill ` +
389
+ `the SUBPROCESS (not this node) so its turn can resume with no lost context.`;
390
+ process.stderr.write(`[crtrd] wedged ${id} (busy ${minutes}+ min with no engine progress, cpu~0%)\n`);
391
+ try {
392
+ recordFault(id, {
393
+ link: 'daemon→node',
394
+ op: 'engine wedge',
395
+ kind: 'wedged',
396
+ retry: { disposition: 'manual' },
397
+ message: label,
398
+ });
399
+ }
400
+ catch {
401
+ /* best-effort — the doctrine wake below is the load-bearing notice */
402
+ }
403
+ try {
404
+ fanDoctrineWake(id, subscribersOf(id), label, {
405
+ reason: 'child-wedged',
406
+ child: id,
407
+ quiet_ms: quietForMs,
408
+ cpu_percent: cpuPercent,
409
+ });
410
+ }
411
+ catch {
412
+ /* best-effort, mirrors every other fan-out in this file */
413
+ }
414
+ }
415
+ // ---------------------------------------------------------------------------
212
416
  // Auth-reload fan (one /login → every live broker follows)
213
417
  // ---------------------------------------------------------------------------
214
418
  /** Path of the shared pi credentials file every broker reads. VENDORED from pi
@@ -306,7 +510,10 @@ export function livenessVerdict(piPidAlive, deadFor) {
306
510
  * the authority on refresh, and the dead-pid refresh branch below then
307
511
  * revives it fresh. Fault recovery now runs in a separate pass: live,
308
512
  * daemon-owned provider faults are retried by inbox nudge, while dead-pid
309
- * crashes still fall through the ordinary resume path below.
513
+ * crashes still fall through the ordinary resume path below. Wedge
514
+ * detection (issue #110) runs here too: handleWedgeDetection no-ops unless
515
+ * the node is BUSY (mid-turn) and its heartbeat has gone quiet past a
516
+ * generous grace, corroborated by near-zero CPU.
310
517
  * • pid null + intent==='idle-release' → dormant by choice; its pid may have
311
518
  * just been cleared by boot reconciliation, but a null pid here is still
312
519
  * that chosen dormancy, not a stranded relaunch — leave it, the second pass
@@ -337,6 +544,7 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
337
544
  if (pid != null && isPidAlive(pid)) {
338
545
  unhealthySince.delete(id);
339
546
  handleYieldStall(row, pid, now);
547
+ handleWedgeDetection(id, pid, now);
340
548
  return;
341
549
  }
342
550
  if (pid == null) {
@@ -642,7 +850,7 @@ export function readPidfile() {
642
850
  }
643
851
  // isPidAlive now lives in canvas/pid.ts (the one shared signal-0 probe at the
644
852
  // lowest layer, so canvas/, runtime/, AND daemon/ all import it down). Re-
645
- // exported here to preserve the public surface consumed by commands/daemon.ts
853
+ // exported here to preserve the public surface consumed by commands/sys/daemon.ts
646
854
  // and the grace-clock test.
647
855
  export { isPidAlive };
648
856
  /** True when a crtrd process is already running (pidfile exists + pid alive). */
@@ -1,6 +1,6 @@
1
1
  // Daemon management helpers — importable without the full command tree.
2
2
  //
3
- // spawnDaemon() is the low-level spawn call shared by `crtr canvas daemon start` and
3
+ // spawnDaemon() is the low-level spawn call shared by `crtr sys daemon start` and
4
4
  // ensureDaemon(). ensureDaemon() is the silent "start if not running" front-
5
5
  // door helper called by the canvas runtime before spawning child nodes.
6
6
  import { spawn } from 'node:child_process';
@@ -68,7 +68,7 @@ export async function spawnDaemon() {
68
68
  // Launch from the crouter-branded host binary so the daemon shows "crouter"
69
69
  // in macOS Full Disk Access, not "node" (see branded-host). NOTE: when crtrd
70
70
  // is launchd-owned, the plist's ProgramArguments must point at the branded
71
- // binary too — this path only covers a manual `crtr canvas daemon start`.
71
+ // binary too — this path only covers a manual `crtr sys daemon start`.
72
72
  const entry = resolveCrtrdEntry();
73
73
  const child = spawn(hostExecPath(), [entry], {
74
74
  detached: true,
@@ -479,8 +479,8 @@ export function registerCanvasNav(pi) {
479
479
  }
480
480
  if (isPlain(data, 'e')) {
481
481
  // Expand → tmux: spread the cursor node's local subtree into a tiled tmux
482
- // window (same action as the alt+c → e menu chord), for parity.
483
- shellCrtr(['canvas', 'tmux-spread', cursorId ?? nodeId], render);
482
+ // window (`crtr surface tmux-spread`).
483
+ shellCrtr(['surface', 'tmux-spread', cursorId ?? nodeId], render);
484
484
  render();
485
485
  return { consume: true };
486
486
  }
@@ -1,4 +1,4 @@
1
- type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start' | 'tool_execution_start' | 'tool_execution_end';
1
+ type PiEvents = 'agent_start' | 'turn_end' | 'agent_end' | 'session_shutdown' | 'session_start' | 'tool_execution_start' | 'tool_execution_end' | 'tool_execution_update' | 'message_update';
2
2
  interface PiLike {
3
3
  on: (event: PiEvents, handler: (event: any, ctx: any) => void | Promise<void>) => void;
4
4
  sendUserMessage: (content: string, options?: {
@@ -30,7 +30,7 @@ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
30
30
  import { join } from 'node:path';
31
31
  import { getNode, jobDir, updateNode, recordPid, setPresence } from '../core/canvas/index.js';
32
32
  import { transition } from '../core/runtime/lifecycle.js';
33
- import { markBusy, clearBusy } from '../core/runtime/busy.js';
33
+ import { markBusy, clearBusy, touchBusy } from '../core/runtime/busy.js';
34
34
  import { recordFault, clearFault } from '../core/runtime/fault.js';
35
35
  import { classify } from '../core/fault-classifier.js';
36
36
  import { evaluateStop } from '../core/runtime/stop-guard.js';
@@ -241,6 +241,25 @@ export function registerCanvasStophook(pi) {
241
241
  // checkpoint roadmap + yield; a resident base (root chat) to promote-or-wrap-
242
242
  // up (it has no roadmap); a terminal worker to promote / push final.
243
243
  const firedBands = new Set();
244
+ // Wedge-heartbeat throttle: message_update/tool_execution_update can fire many
245
+ // times per second while streaming, and touchBusy is a disk write — rate-limit
246
+ // it to at most once per HEARTBEAT_THROTTLE_MS. The daemon's wedge grace is
247
+ // measured in minutes, so this throttle costs it nothing. Also clears any
248
+ // stale `daemon→node` wedge fault at the same throttled cadence: a sign of
249
+ // life should both refresh the heartbeat marker AND clear a stale wedge
250
+ // notice as one coherent act, same as every coarser progress site below
251
+ // (tool_execution_start/end, turn_end, agent_end) already does. clearFault is
252
+ // a cheap no-op when no fault exists, so this stays safe to call every time.
253
+ const HEARTBEAT_THROTTLE_MS = 3_000;
254
+ let lastHeartbeatAt = 0;
255
+ const heartbeat = () => {
256
+ const now = Date.now();
257
+ if (now - lastHeartbeatAt < HEARTBEAT_THROTTLE_MS)
258
+ return;
259
+ lastHeartbeatAt = now;
260
+ touchBusy(nodeId);
261
+ clearFault(nodeId, { link: 'daemon→node' });
262
+ };
244
263
  // ---------------------------------------------------------------------------
245
264
  // session_start — capture pi's session id, and detect `/new`.
246
265
  //
@@ -336,6 +355,36 @@ export function registerCanvasStophook(pi) {
336
355
  try {
337
356
  lastActivity = summarizeActivity(String(event?.toolName ?? ''), event?.args);
338
357
  flushTelemetry(jobDirPath, totalIn, totalOut, model, lastContext, lastActivity);
358
+ touchBusy(nodeId); // wedge heartbeat: a fresh tool starting is a sign of life
359
+ clearFault(nodeId, { link: 'daemon→node' }); // a tool actually starting clears any prior wedge notice
360
+ }
361
+ catch { /* best-effort */ }
362
+ });
363
+ // ---------------------------------------------------------------------------
364
+ // tool_execution_update — wedge heartbeat + fault clear only. Fired with
365
+ // partial/streaming tool output (when a tool reports progress); throttled via
366
+ // `heartbeat()` since it can arrive many times per second. Most tools (e.g.
367
+ // bash) don't stream progress today, so this is currently a light-touch no-op
368
+ // for them — it exists so any tool that DOES stream partial output (now or in
369
+ // the future) counts as live progress instead of tripping the wedge detector,
370
+ // AND clears any wedge notice that already fired before this progress resumed.
371
+ // ---------------------------------------------------------------------------
372
+ pi.on('tool_execution_update', () => {
373
+ try {
374
+ heartbeat();
375
+ }
376
+ catch { /* best-effort */ }
377
+ });
378
+ // ---------------------------------------------------------------------------
379
+ // message_update — wedge heartbeat + fault clear only. Fired token-by-token
380
+ // while the assistant is actively streaming a reply — genuine progress,
381
+ // throttled the same way, and equally clears a stale wedge notice if streaming
382
+ // resumes before a coarser boundary. A node stuck streaming forever without
383
+ // ever firing this is exactly the daemon's busy+quiet wedge signature.
384
+ // ---------------------------------------------------------------------------
385
+ pi.on('message_update', () => {
386
+ try {
387
+ heartbeat();
339
388
  }
340
389
  catch { /* best-effort */ }
341
390
  });
@@ -353,6 +402,8 @@ export function registerCanvasStophook(pi) {
353
402
  // refreshes on its first tool — the desired outcome.)
354
403
  pi.on('tool_execution_end', (_event, ctx) => {
355
404
  try {
405
+ touchBusy(nodeId); // wedge heartbeat: the tool returning is a sign of life
406
+ clearFault(nodeId, { link: 'daemon→node' }); // a tool that was "stuck" just returned — the wedge notice was resolved
356
407
  if (getNode(nodeId)?.intent === 'refresh') {
357
408
  clearBusy(nodeId); // agent_end won't run to clear it; harmless if it lingers
358
409
  try {
@@ -422,6 +473,8 @@ export function registerCanvasStophook(pi) {
422
473
  lastContext = contextTokens;
423
474
  // Fire-and-forget: flushTelemetry uses synchronous fs writes and never throws.
424
475
  flushTelemetry(jobDirPath, totalIn, totalOut, model, contextTokens, lastActivity);
476
+ touchBusy(nodeId); // wedge heartbeat: a turn ending is a sign of life
477
+ clearFault(nodeId, { link: 'daemon→node' }); // reaching turn_end proves this turn wasn't wedged
425
478
  // Context-size steering: fire the current band once, with lifecycle-specific
426
479
  // guidance (lifecycle is read live — a terminal worker may have promoted to
427
480
  // resident since launch).
@@ -473,8 +526,12 @@ export function registerCanvasStophook(pi) {
473
526
  // removed, so the handler no longer needs to be async — the node reaches its
474
527
  // subscribers ONLY through its own explicit `crtr push` calls.
475
528
  // The turn has ended regardless of how it routes below — clear the mid-turn
476
- // marker FIRST so a focus-away from this now-parked node despawns it.
529
+ // marker FIRST so a focus-away from this now-parked node despawns it. Also
530
+ // clear any wedge notice: reaching agent_end at all proves the turn ended
531
+ // and the engine was never permanently stuck (covers an abort/error path
532
+ // that skips the tool_execution_end/turn_end clear sites above).
477
533
  clearBusy(nodeId);
534
+ clearFault(nodeId, { link: 'daemon→node' });
478
535
  try {
479
536
  const messages = Array.isArray(event?.messages) ? event.messages : [];
480
537
  // Accumulate tokens from the final batch (edge case: a turn that fired