@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.
- package/dist/build-root.js +2 -6
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-personas/runtime-base.md +1 -1
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +4 -4
- package/dist/builtin-views/canvas/core.mjs +3 -2
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +21 -205
- package/dist/clients/attach/attach-cmd.js +383 -386
- package/dist/clients/web/web-cmd.js +7 -7
- package/dist/commands/__tests__/human.test.js +1 -15
- package/dist/commands/attention.js +9 -6
- package/dist/commands/canvas-history/search.js +2 -2
- package/dist/commands/canvas-history/show.js +3 -3
- package/dist/commands/canvas-history.js +1 -1
- package/dist/commands/canvas-issue.js +1 -0
- package/dist/commands/canvas-snapshot.js +1 -0
- package/dist/commands/canvas.js +10 -11
- package/dist/commands/chord.js +1 -0
- package/dist/commands/human/prompts.js +0 -1
- package/dist/commands/human/queue.js +1 -0
- package/dist/commands/node-snapshot.js +1 -0
- package/dist/commands/node.js +12 -11
- package/dist/commands/push.d.ts +0 -1
- package/dist/commands/push.js +2 -228
- package/dist/commands/revive.js +2 -2
- package/dist/commands/{canvas-tmux-spread.js → surface-tmux-spread.js} +9 -6
- package/dist/commands/surface.js +3 -2
- package/dist/commands/sys/daemon.d.ts +2 -0
- package/dist/commands/{daemon.js → sys/daemon.js} +12 -12
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/commands/sys.js +4 -3
- package/dist/core/__tests__/daemon-boot.test.js +1 -1
- package/dist/core/__tests__/daemon-wedge.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-wedge.test.js +87 -0
- package/dist/core/__tests__/hearth-bootstrap.test.js +2 -2
- package/dist/core/__tests__/history-inbox.test.d.ts +1 -0
- package/dist/core/__tests__/history-inbox.test.js +105 -0
- package/dist/core/__tests__/kickoff.test.js +3 -3
- package/dist/core/__tests__/revive.test.js +39 -1
- package/dist/core/canvas/history.d.ts +1 -1
- package/dist/core/canvas/history.js +90 -3
- package/dist/core/canvas/paths.d.ts +6 -0
- package/dist/core/canvas/paths.js +9 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/feed/inbox.d.ts +7 -12
- package/dist/core/feed/inbox.js +45 -29
- package/dist/core/hearth/providers/blaxel-bootstrap.js +1 -1
- package/dist/core/preview-registry.js +14 -112
- package/dist/core/render.js +1 -1
- package/dist/core/runtime/busy.d.ts +15 -0
- package/dist/core/runtime/busy.js +32 -1
- package/dist/core/runtime/close.d.ts +10 -0
- package/dist/core/runtime/close.js +18 -13
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/kickoff.js +5 -7
- package/dist/core/runtime/placement.js +1 -1
- package/dist/core/runtime/revive.js +9 -1
- package/dist/core/runtime/tmux.d.ts +2 -2
- package/dist/core/runtime/tmux.js +2 -2
- package/dist/daemon/crtrd-cli.js +1 -1
- package/dist/daemon/crtrd.d.ts +24 -0
- package/dist/daemon/crtrd.js +213 -5
- package/dist/daemon/manage.js +2 -2
- package/dist/pi-extensions/canvas-nav.js +2 -2
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +59 -2
- package/dist/web-client/assets/{index-IAJVtuVe.js → index-MSKSWwV0.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/package.json +1 -1
- package/dist/commands/daemon.d.ts +0 -2
- /package/dist/commands/{canvas-tmux-spread.d.ts → surface-tmux-spread.d.ts} +0 -0
package/dist/daemon/crtrd.js
CHANGED
|
@@ -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). */
|
package/dist/daemon/manage.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
483
|
-
shellCrtr(['
|
|
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
|