@crouton-kit/crouter 0.3.18 → 0.3.19
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 +6 -0
- package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
- package/dist/builtin-memory/crouter-development/personas.md +117 -0
- package/dist/builtin-memory/crouter-development/plugins.md +163 -0
- package/dist/builtin-memory/design.md +60 -0
- package/dist/builtin-memory/development.md +116 -0
- package/dist/builtin-memory/planning.md +66 -0
- package/dist/builtin-memory/spec.md +99 -0
- package/dist/builtin-personas/design/orchestrator.md +1 -1
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/lifecycle/resident.md +1 -1
- package/dist/builtin-personas/lifecycle/terminal.md +5 -2
- package/dist/builtin-personas/orchestration-kernel.md +15 -24
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/orchestrator.md +1 -1
- package/dist/builtin-personas/waiting.md +8 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
- package/dist/builtin-views/_lib/states.mjs +161 -0
- package/dist/builtin-views/canvas/client.mjs +303 -0
- package/dist/builtin-views/canvas/view.mjs +576 -0
- package/dist/builtin-views/git-pr/client.mjs +440 -0
- package/dist/builtin-views/git-pr/view.mjs +675 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
- package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
- package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
- package/dist/builtin-views/inbox/view.mjs +889 -0
- package/dist/builtin-views/linkedin/client.mjs +610 -0
- package/dist/builtin-views/linkedin/view.mjs +1171 -0
- package/dist/clients/attach/attach-cmd.d.ts +2 -0
- package/dist/clients/attach/attach-cmd.js +354 -0
- package/dist/clients/attach/chat-view.d.ts +77 -0
- package/dist/clients/attach/chat-view.js +450 -0
- package/dist/clients/attach/clipboard-image.d.ts +16 -0
- package/dist/clients/attach/clipboard-image.js +113 -0
- package/dist/clients/attach/config-load.d.ts +31 -0
- package/dist/clients/attach/config-load.js +113 -0
- package/dist/clients/attach/extension-dialogs.d.ts +29 -0
- package/dist/clients/attach/extension-dialogs.js +101 -0
- package/dist/clients/attach/input-controller.d.ts +54 -0
- package/dist/clients/attach/input-controller.js +204 -0
- package/dist/clients/attach/slash-commands.d.ts +36 -0
- package/dist/clients/attach/slash-commands.js +200 -0
- package/dist/clients/attach/view-socket.d.ts +48 -0
- package/dist/clients/attach/view-socket.js +126 -0
- package/dist/commands/attention.js +3 -3
- package/dist/commands/canvas-prune.js +1 -1
- package/dist/commands/daemon.js +3 -2
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/queue.js +43 -8
- package/dist/commands/human/shared.d.ts +2 -0
- package/dist/commands/memory/find.d.ts +1 -0
- package/dist/commands/memory/find.js +180 -0
- package/dist/commands/memory/lint.d.ts +1 -0
- package/dist/commands/memory/lint.js +140 -0
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +79 -0
- package/dist/commands/memory/read.js +103 -0
- package/dist/commands/memory/shared.d.ts +30 -0
- package/dist/commands/memory/shared.js +122 -0
- package/dist/commands/memory/write.d.ts +1 -0
- package/dist/commands/memory/write.js +85 -0
- package/dist/commands/memory.d.ts +2 -0
- package/dist/commands/memory.js +27 -0
- package/dist/commands/node.d.ts +3 -2
- package/dist/commands/node.js +607 -61
- package/dist/commands/pkg/market-manage.js +1 -1
- package/dist/commands/push.js +6 -6
- package/dist/commands/revive.js +1 -1
- package/dist/commands/skill/author.js +1 -1
- package/dist/commands/skill/shared.d.ts +1 -8
- package/dist/commands/skill/shared.js +2 -55
- package/dist/commands/skill.js +9 -14
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/update.js +1 -1
- package/dist/commands/view-cycle.d.ts +2 -0
- package/dist/commands/view-cycle.js +125 -0
- package/dist/commands/view-list.d.ts +2 -0
- package/dist/commands/view-list.js +66 -0
- package/dist/commands/view-new.d.ts +2 -0
- package/dist/commands/view-new.js +70 -0
- package/dist/commands/view-pick.d.ts +2 -0
- package/dist/commands/view-pick.js +119 -0
- package/dist/commands/view-run.d.ts +2 -0
- package/dist/commands/view-run.js +191 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +29 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
- package/dist/core/__tests__/cascade-close.test.js +12 -2
- package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
- package/dist/core/__tests__/child-death-wake.test.js +245 -0
- package/dist/core/__tests__/context-intro.test.js +76 -62
- package/dist/core/__tests__/daemon-boot.test.js +14 -5
- package/dist/core/__tests__/daemon-liveness.test.js +34 -9
- package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
- package/dist/core/__tests__/detach-focus.test.js +206 -0
- package/dist/core/__tests__/draw-style.test.d.ts +1 -0
- package/dist/core/__tests__/draw-style.test.js +258 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
- package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
- package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
- package/dist/core/__tests__/helpers/harness.d.ts +9 -0
- package/dist/core/__tests__/helpers/harness.js +111 -1
- package/dist/core/__tests__/home-session.test.js +41 -8
- package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
- package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
- package/dist/core/__tests__/kickoff.test.js +37 -3
- package/dist/core/__tests__/live-mutation.test.js +50 -33
- package/dist/core/__tests__/memory.test.js +23 -115
- package/dist/core/__tests__/placement-focus.test.js +5 -0
- package/dist/core/__tests__/placement-teardown.test.js +54 -11
- package/dist/core/__tests__/relaunch.test.js +4 -3
- package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
- package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
- package/dist/core/__tests__/spawn-root.test.js +10 -0
- package/dist/core/__tests__/spike-harness.test.js +1 -0
- package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
- package/dist/core/__tests__/wake-bearings.test.js +156 -0
- package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
- package/dist/core/__tests__/wake-origin.test.js +110 -0
- package/dist/core/bootstrap.js +1 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +24 -2
- package/dist/core/canvas/browse/model.d.ts +38 -2
- package/dist/core/canvas/browse/model.js +134 -10
- package/dist/core/canvas/browse/render.d.ts +6 -12
- package/dist/core/canvas/browse/render.js +72 -104
- package/dist/core/canvas/canvas.js +15 -22
- package/dist/core/canvas/db.js +46 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/pid.d.ts +4 -0
- package/dist/core/canvas/pid.js +23 -0
- package/dist/core/canvas/render.d.ts +11 -2
- package/dist/core/canvas/render.js +69 -0
- package/dist/core/canvas/types.d.ts +85 -3
- package/dist/core/canvas/wakeups.d.ts +76 -0
- package/dist/core/canvas/wakeups.js +185 -0
- package/dist/core/config.js +4 -1
- package/dist/core/frontmatter.js +37 -124
- package/dist/core/memory-resolver.d.ts +49 -0
- package/dist/core/memory-resolver.js +141 -0
- package/dist/core/personas/loader.d.ts +7 -0
- package/dist/core/personas/loader.js +36 -5
- package/dist/core/personas/resolve.js +13 -3
- package/dist/core/predicate.d.ts +63 -0
- package/dist/core/predicate.js +189 -0
- package/dist/core/resolver.js +26 -5
- package/dist/core/runtime/bearings.d.ts +53 -12
- package/dist/core/runtime/bearings.js +132 -59
- package/dist/core/runtime/broker-cli.d.ts +1 -0
- package/dist/core/runtime/broker-cli.js +46 -0
- package/dist/core/runtime/broker-protocol.d.ts +332 -0
- package/dist/core/runtime/broker-protocol.js +153 -0
- package/dist/core/runtime/broker-sdk.d.ts +48 -0
- package/dist/core/runtime/broker-sdk.js +72 -0
- package/dist/core/runtime/broker.d.ts +55 -0
- package/dist/core/runtime/broker.js +1128 -0
- package/dist/core/runtime/close.js +35 -6
- package/dist/core/runtime/host.d.ts +53 -0
- package/dist/core/runtime/host.js +186 -0
- package/dist/core/runtime/kickoff.d.ts +2 -1
- package/dist/core/runtime/kickoff.js +91 -5
- package/dist/core/runtime/launch.d.ts +45 -2
- package/dist/core/runtime/launch.js +65 -2
- package/dist/core/runtime/lifecycle.js +23 -6
- package/dist/core/runtime/memory.d.ts +2 -42
- package/dist/core/runtime/memory.js +11 -162
- package/dist/core/runtime/nodes.d.ts +33 -0
- package/dist/core/runtime/nodes.js +59 -1
- package/dist/core/runtime/persona.js +21 -11
- package/dist/core/runtime/pi-vendored.d.ts +18 -0
- package/dist/core/runtime/pi-vendored.js +49 -0
- package/dist/core/runtime/placement.d.ts +32 -14
- package/dist/core/runtime/placement.js +206 -52
- package/dist/core/runtime/promote.d.ts +0 -6
- package/dist/core/runtime/promote.js +1 -12
- package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
- package/dist/core/runtime/{demote.js → recycle.js} +27 -11
- package/dist/core/runtime/reset.js +8 -6
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +19 -0
- package/dist/core/runtime/spawn.js +75 -22
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +6 -1
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +28 -0
- package/dist/core/runtime/tmux.js +80 -6
- package/dist/core/scope.d.ts +11 -0
- package/dist/core/scope.js +39 -0
- package/dist/core/spawn.d.ts +5 -0
- package/dist/core/spawn.js +20 -2
- package/dist/core/substrate/gate.d.ts +13 -0
- package/dist/core/substrate/gate.js +21 -0
- package/dist/core/substrate/index.d.ts +7 -0
- package/dist/core/substrate/index.js +18 -0
- package/dist/core/substrate/on-read.d.ts +14 -0
- package/dist/core/substrate/on-read.js +292 -0
- package/dist/core/substrate/render.d.ts +25 -0
- package/dist/core/substrate/render.js +256 -0
- package/dist/core/substrate/schema.d.ts +76 -0
- package/dist/core/substrate/schema.js +124 -0
- package/dist/core/substrate/session-cache.d.ts +30 -0
- package/dist/core/substrate/session-cache.js +77 -0
- package/dist/core/substrate/subject.d.ts +41 -0
- package/dist/core/substrate/subject.js +54 -0
- package/dist/core/tui/contract.d.ts +83 -0
- package/dist/core/tui/contract.js +8 -0
- package/dist/core/tui/draw.d.ts +96 -0
- package/dist/core/tui/draw.js +339 -0
- package/dist/core/tui/host.d.ts +29 -0
- package/dist/core/tui/host.js +379 -0
- package/dist/core/tui/loader.d.ts +16 -0
- package/dist/core/tui/loader.js +94 -0
- package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
- package/dist/core/wake.d.ts +86 -0
- package/dist/core/wake.js +308 -0
- package/dist/daemon/crtrd.d.ts +29 -4
- package/dist/daemon/crtrd.js +662 -46
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
- package/dist/pi-extensions/canvas-commands.d.ts +3 -0
- package/dist/pi-extensions/canvas-commands.js +10 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
- package/dist/pi-extensions/canvas-context-intro.js +55 -15
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
- package/dist/pi-extensions/canvas-nav.d.ts +3 -0
- package/dist/pi-extensions/canvas-nav.js +87 -23
- package/dist/pi-extensions/canvas-stophook.js +17 -8
- package/dist/pi-extensions/canvas-view.d.ts +21 -0
- package/dist/pi-extensions/canvas-view.js +75 -0
- package/dist/prompts/skill.js +19 -26
- package/dist/prompts/view.d.ts +7 -0
- package/dist/prompts/view.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +1 -0
- package/package.json +9 -4
- package/dist/commands/skill/find.d.ts +0 -4
- package/dist/commands/skill/find.js +0 -257
- package/dist/commands/skill/read.js +0 -91
- /package/dist/commands/{skill → memory}/read.d.ts +0 -0
- /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
package/dist/daemon/crtrd.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// crtrd — the thin supervisor daemon. One instance per canvas.
|
|
2
2
|
//
|
|
3
|
-
// Sole responsibility: supervise
|
|
4
|
-
//
|
|
3
|
+
// Sole responsibility: supervise engine-container exit (a tmux pane or a
|
|
4
|
+
// headless broker process — `host_kind`) and revive nodes. No orchestration
|
|
5
|
+
// logic lives here. The daemon is a process-lifecycle watcher.
|
|
5
6
|
//
|
|
6
|
-
// Model (v3: liveness is PANE-existence, not window-existence — a manual
|
|
7
|
-
// move-pane/join-pane/break-pane must never read as a node death
|
|
7
|
+
// Model (v3: tmux liveness is PANE-existence, not window-existence — a manual
|
|
8
|
+
// move-pane/join-pane/break-pane must never read as a node death; broker-hosted
|
|
9
|
+
// nodes are supervised via their recorded pid — see handleBrokerLiveness)
|
|
8
10
|
// • Poll every intervalMs (default 2000ms).
|
|
9
|
-
// • For each active|idle node: check whether its tmux PANE is still alive
|
|
11
|
+
// • For each active|idle tmux node: check whether its tmux PANE is still alive
|
|
10
12
|
// (isNodePaneAlive; window-existence is only a legacy/no-pane fallback).
|
|
11
13
|
// • Pane alive → reconcile its LOCATION (follow any manual move; lazy-backfill
|
|
12
14
|
// a legacy row's pane), then judge pi liveness — healthy, skip otherwise.
|
|
@@ -25,16 +27,21 @@
|
|
|
25
27
|
// A PID file at crtrHome()/crtrd.pid prevents double-runs. On start, if the
|
|
26
28
|
// file exists and the recorded pid is alive, we refuse to start (exit 0).
|
|
27
29
|
// On stop (SIGINT/SIGTERM/exit) we remove the file.
|
|
28
|
-
import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, } from 'node:fs';
|
|
30
|
+
import { writeFileSync, readFileSync, rmSync, existsSync, mkdirSync, statSync, } from 'node:fs';
|
|
29
31
|
import { join } from 'node:path';
|
|
30
|
-
import { crtrHome } from '../core/canvas/paths.js';
|
|
31
|
-
import { listNodes, getRow, setPresence, getNode, hasActiveLiveSubscription, } from '../core/canvas/index.js';
|
|
32
|
+
import { crtrHome, nodeDir, jobDir } from '../core/canvas/paths.js';
|
|
33
|
+
import { listNodes, getRow, setPresence, getNode, hasActiveLiveSubscription, hasPendingSelfWake, dueWakes, consumeWake, advanceWake, listFocuses, closeFocusRow, } from '../core/canvas/index.js';
|
|
32
34
|
import { transition } from '../core/runtime/lifecycle.js';
|
|
33
35
|
import { isBusy } from '../core/runtime/busy.js';
|
|
34
|
-
import {
|
|
36
|
+
import { isPidAlive } from '../core/canvas/pid.js';
|
|
37
|
+
import { reconcile, listLivePanes, tearDownNode } from '../core/runtime/placement.js';
|
|
38
|
+
import { hostFor } from '../core/runtime/host.js';
|
|
35
39
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
40
|
+
import { spawnChild } from '../core/runtime/spawn.js';
|
|
41
|
+
import { wakeOriginFrom } from '../core/runtime/bearings.js';
|
|
42
|
+
import { pushUrgent, pushUpdate } from '../core/feed/feed.js';
|
|
43
|
+
import { appendInbox, readInboxSince, readCursor } from '../core/feed/inbox.js';
|
|
44
|
+
import { nextSlotAfter } from '../core/wake.js';
|
|
38
45
|
/** Surface a vehicle that never booted.
|
|
39
46
|
*
|
|
40
47
|
* `spawnChild` returns status="active" the instant the tmux window opens — it
|
|
@@ -55,6 +62,37 @@ async function surfaceBootFailure(meta) {
|
|
|
55
62
|
`If the work still needs doing, re-spawn it; if spawns keep dying, spawn fewer at a time.`;
|
|
56
63
|
await pushUrgent(meta.node_id, body, { from: meta.node_id });
|
|
57
64
|
}
|
|
65
|
+
/** Wake an inbox-waiting PARENT when the daemon ends a child the child did NOT
|
|
66
|
+
* end itself — a mid-generation CRASH (the pane died inside a turn) or a quiet-
|
|
67
|
+
* turn FINALIZE (the pane was dismissed after the turn ended with nothing live
|
|
68
|
+
* to wait for). Mirrors surfaceBootFailure: a single push fanned to
|
|
69
|
+
* subscribersOf(child), so a purely-inbox-waiting parent is woken on the SAME
|
|
70
|
+
* channel as a `push final` — its live inbox-watcher, or this daemon's dormant-
|
|
71
|
+
* revive second pass. Without it a parent that delegated and just stopped hangs
|
|
72
|
+
* forever on these outcomes (D-1 finding: today only `push final` / never-booted
|
|
73
|
+
* wake it). The doctrine "delegate and stop; the runtime wakes you" requires
|
|
74
|
+
* EVERY terminal child outcome to reach the parent.
|
|
75
|
+
*
|
|
76
|
+
* Fires ONLY on genuine death — NEVER on healthy dormancy. The CALLER owns that
|
|
77
|
+
* boundary: this is invoked from the crash + finalize branches only; a child
|
|
78
|
+
* that ended its turn still awaiting a live grandchild OR holding a pending
|
|
79
|
+
* self-wake routes to `release` (revivable) and never reaches here. A CRASH is
|
|
80
|
+
* URGENT (a fault, like a boot failure); a quiet FINALIZE is a normal update (a
|
|
81
|
+
* clean dismissal — wake the dormant parent without interrupting a live one
|
|
82
|
+
* mid-turn). */
|
|
83
|
+
async function surfaceChildDeath(meta, cause) {
|
|
84
|
+
if (cause === 'crash') {
|
|
85
|
+
await pushUrgent(meta.node_id, `⚠ Child died — \`${meta.name}\` (${meta.kind}) was killed mid-task.\n\n` +
|
|
86
|
+
`Its pi vehicle went away mid-generation (the pane was closed or crashed while it was ` +
|
|
87
|
+
`working), so it never pushed a final report. Re-spawn it if the work still needs doing.`, { from: meta.node_id });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
await pushUpdate(meta.node_id, `\`${meta.name}\` (${meta.kind}) ended without a final report.\n\n` +
|
|
91
|
+
`Its turn ended and its pane was closed with nothing live left to wait for, so the runtime ` +
|
|
92
|
+
`finalized it. It pushed no \`final\` — check its reports/ for anything it left, and re-spawn ` +
|
|
93
|
+
`if the result is incomplete.`, { from: meta.node_id });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
58
96
|
const DEFAULT_INTERVAL_MS = 2000;
|
|
59
97
|
// How long a node's pi may be observed dead-while-its-window-lives before the
|
|
60
98
|
// daemon revives it. MUST exceed worst-case pi boot time: a normal in-place
|
|
@@ -65,6 +103,134 @@ const REVIVE_GRACE_MS = 20_000;
|
|
|
65
103
|
// Per-node first-observed-dead timestamps, for the grace window above. In-memory
|
|
66
104
|
// only — a daemon restart resets it (worst case: one extra grace interval).
|
|
67
105
|
const unhealthySince = new Map();
|
|
106
|
+
// How long a node may sit with intent='refresh', its turn OVER (busy marker
|
|
107
|
+
// absent) and its pi still ALIVE before the daemon concludes the yield stalled
|
|
108
|
+
// and kills the pi. The healthy window is seconds: agent_end dispatches the
|
|
109
|
+
// in-place respawn (which kills the old pi) immediately after clearing busy.
|
|
110
|
+
// Minutes of that state means the shutdown/respawn path hung (observed at
|
|
111
|
+
// ~200k-token contexts) and nothing else will ever unstick it — the daemon
|
|
112
|
+
// only revives DEAD pis.
|
|
113
|
+
export const YIELD_STALL_GRACE_MS = 3 * 60_000;
|
|
114
|
+
// After a yield-stall SIGTERM, how long the pi gets to die gracefully before
|
|
115
|
+
// escalating to SIGKILL (a pi hung at context overflow may not run handlers).
|
|
116
|
+
const KILL_ESCALATE_MS = 20_000;
|
|
117
|
+
// Per-node yield-stall clocks (audit 2026-06-09, Bug 1). In-memory like
|
|
118
|
+
// unhealthySince — a daemon restart resets them (worst case: one extra grace).
|
|
119
|
+
const yieldStallSince = new Map();
|
|
120
|
+
const yieldTermAt = new Map();
|
|
121
|
+
// Wake-failure dedup latch (third pass): wakeup_ids we've already fail-loud
|
|
122
|
+
// notified about, so a permanently-broken spawn-cron (or a quarantined recurrence)
|
|
123
|
+
// notifies the armer ONCE and then stays quiet until the next success or cancel —
|
|
124
|
+
// the unrotated, append-only inbox.jsonl must not be flooded (Min-9). In-memory
|
|
125
|
+
// only, like unhealthySince; a daemon restart resets it (worst case: one repeat
|
|
126
|
+
// notice). A successful spawn clears its entry so a future failure re-notifies.
|
|
127
|
+
const notifiedWakeFailures = new Set();
|
|
128
|
+
// How old a spawned-but-sessionless node must be before the daemon treats it as
|
|
129
|
+
// "never launched" and relaunches it. MUST generously exceed worst-case pi boot
|
|
130
|
+
// under load: a healthy fresh spawn is sessionless (no pi_session_id, no pid,
|
|
131
|
+
// zero cycles) for its whole boot, and relaunching into that window would
|
|
132
|
+
// double-spawn.
|
|
133
|
+
export const NEVER_LAUNCHED_GRACE_MS = 5 * 60_000;
|
|
134
|
+
/** Never-launched decision (audit 2026-06-09, Bug 4 — node mq45b6ch-9ecc2f03):
|
|
135
|
+
* a spawn that opened its window but whose pi never came up leaves a row with
|
|
136
|
+
* ZERO cycles, NO session, NO pid — and, when the window lingers (e.g. a
|
|
137
|
+
* remain-on-exit corpse), the live-pane pass read "no pid to judge" and left it
|
|
138
|
+
* forever; the parent waited on a node that was never going to run. Pure core:
|
|
139
|
+
* a node that has ever booted (session recorded) or ever been revived (cycles
|
|
140
|
+
* bumped) is NOT never-launched — the cycles gate also bounds this to ONE
|
|
141
|
+
* automatic retry (the relaunch bumps cycles), so a persistently failing
|
|
142
|
+
* launch can't loop. Age below the grace is a normal in-flight boot. */
|
|
143
|
+
export function neverLaunchedVerdict(piSessionId, cycles, ageMs) {
|
|
144
|
+
if (piSessionId != null || cycles > 0)
|
|
145
|
+
return 'leave';
|
|
146
|
+
return ageMs >= NEVER_LAUNCHED_GRACE_MS ? 'relaunch' : 'leave';
|
|
147
|
+
}
|
|
148
|
+
/** Enact the never-launched relaunch for a pane-alive, pid-less node (Bug 4):
|
|
149
|
+
* verify against meta (pi_session_id is identity, read on demand), reap the
|
|
150
|
+
* stale spawn window, and relaunch fresh via reviveNode — whose kickoff
|
|
151
|
+
* carries the node's goal (context/initial-prompt.md) when no roadmap exists,
|
|
152
|
+
* so the relaunched worker receives its original mandate. */
|
|
153
|
+
function handleNeverLaunched(row, now, revivedThisTick) {
|
|
154
|
+
const age = now - Date.parse(row.created); // NaN compares false → leave
|
|
155
|
+
if (!(age >= NEVER_LAUNCHED_GRACE_MS))
|
|
156
|
+
return;
|
|
157
|
+
const meta = getNode(row.node_id);
|
|
158
|
+
if (meta === null)
|
|
159
|
+
return;
|
|
160
|
+
if (neverLaunchedVerdict(meta.pi_session_id ?? null, meta.cycles ?? 0, age) !== 'relaunch') {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
process.stderr.write(`[crtrd] relaunch ${row.node_id} (spawned but never launched: 0 cycles, no session, age ${Math.round(age / 1000)}s)\n`);
|
|
164
|
+
tearDownNode(row.node_id); // reap the stale spawn pane — reviveNode opens a fresh window
|
|
165
|
+
reviveNode(row.node_id, { resume: false });
|
|
166
|
+
revivedThisTick.add(row.node_id);
|
|
167
|
+
}
|
|
168
|
+
/** Yield-stall decision (audit 2026-06-09, Bug 1 — node mq5v9hfa-74493d45 and
|
|
169
|
+
* three sibling orchestrators): a `node yield` records intent='refresh' and
|
|
170
|
+
* agent_end dispatches the in-place respawn, but at very large contexts the pi
|
|
171
|
+
* process can HANG instead of exiting — leaving pi alive, the turn over, and
|
|
172
|
+
* the intent pending forever. The daemon only revives DEAD pis, so without
|
|
173
|
+
* this verdict the node is permanently stuck (no revive, no wake). Pure core,
|
|
174
|
+
* mirroring livenessVerdict; the kill side effects live in handleYieldStall.
|
|
175
|
+
*
|
|
176
|
+
* 'kill' ONLY when ALL of: pi alive, intent='refresh', the turn is over (busy
|
|
177
|
+
* marker absent — a node may legitimately keep working for many minutes after
|
|
178
|
+
* running `node yield` mid-turn, and a working pi must never be killed), and
|
|
179
|
+
* the state has persisted past YIELD_STALL_GRACE_MS. */
|
|
180
|
+
export function yieldStallVerdict(piPidAlive, intent, busy, stalledFor) {
|
|
181
|
+
if (piPidAlive !== true)
|
|
182
|
+
return 'leave';
|
|
183
|
+
if (intent !== 'refresh')
|
|
184
|
+
return 'leave';
|
|
185
|
+
if (busy)
|
|
186
|
+
return 'leave';
|
|
187
|
+
if (stalledFor === null || stalledFor < YIELD_STALL_GRACE_MS)
|
|
188
|
+
return 'pending';
|
|
189
|
+
return 'kill';
|
|
190
|
+
}
|
|
191
|
+
/** Enact a yield-stall (Bug 1): clock the stalled state, then SIGTERM the hung
|
|
192
|
+
* pi (escalating to SIGKILL if it survives KILL_ESCALATE_MS — a pi hung at
|
|
193
|
+
* context overflow may never run its handlers). Once the pi is dead the
|
|
194
|
+
* ORDINARY machinery finishes the job: the pane-alive/pi-dead grace path
|
|
195
|
+
* revives with resume=false (intent is still 'refresh'), i.e. the fresh
|
|
196
|
+
* roadmap-revive the yield asked for. Safe against a graceful SIGTERM
|
|
197
|
+
* shutdown racing us: markCleanExitDone no-ops while intent is pending, so a
|
|
198
|
+
* dying pi can't mis-finalize the node. */
|
|
199
|
+
function handleYieldStall(row, pid, now) {
|
|
200
|
+
const id = row.node_id;
|
|
201
|
+
if (row.intent !== 'refresh' || isBusy(id)) {
|
|
202
|
+
yieldStallSince.delete(id);
|
|
203
|
+
yieldTermAt.delete(id);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const since = yieldStallSince.get(id);
|
|
207
|
+
if (since === undefined) {
|
|
208
|
+
yieldStallSince.set(id, now);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (yieldStallVerdict(true, row.intent, false, now - since) !== 'kill')
|
|
212
|
+
return;
|
|
213
|
+
const termed = yieldTermAt.get(id);
|
|
214
|
+
if (termed === undefined) {
|
|
215
|
+
process.stderr.write(`[crtrd] kill ${id} (yield-stall: pi ${pid} still alive ${Math.round((now - since) / 1000)}s after its refresh-yield turn ended — SIGTERM)\n`);
|
|
216
|
+
try {
|
|
217
|
+
process.kill(pid, 'SIGTERM');
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
/* already gone */
|
|
221
|
+
}
|
|
222
|
+
yieldTermAt.set(id, now);
|
|
223
|
+
}
|
|
224
|
+
else if (now - termed >= KILL_ESCALATE_MS) {
|
|
225
|
+
process.stderr.write(`[crtrd] kill ${id} (yield-stall: pi ${pid} survived SIGTERM — SIGKILL)\n`);
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, 'SIGKILL');
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
/* already gone */
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
68
234
|
/** Decide what to do with a node whose tmux pane is alive, from its pi
|
|
69
235
|
* liveness and how long it's been dead. Pure — the time-and-tmux side effects
|
|
70
236
|
* live in handleLiveWindow; this is the unit-testable core.
|
|
@@ -82,22 +248,41 @@ export function livenessVerdict(piPidAlive, deadFor) {
|
|
|
82
248
|
* alive (an inline root runs pi under a persistent login shell that survives
|
|
83
249
|
* pi's death), so gauge liveness on the recorded pid and revive a dead pi once
|
|
84
250
|
* it's been dead past the grace window. */
|
|
85
|
-
function handleLiveWindow(row, now) {
|
|
251
|
+
function handleLiveWindow(row, now, revivedThisTick) {
|
|
86
252
|
const id = row.node_id;
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
253
|
+
// Defensive: an idle-release node whose pane is still alive must NOT be
|
|
254
|
+
// grace-revived here — it is dormant BY CHOICE and is woken only by a worker's
|
|
255
|
+
// inbox push (the second pass below); grace-reviving would pre-empt that and
|
|
256
|
+
// churn the pane. (Focused-await nodes now stay pi-LIVE instead of releasing —
|
|
257
|
+
// see the stophook awaiting branch — so reaching this with a live pane is rare;
|
|
258
|
+
// the guard stays as a cheap safety net, e.g. a remain-on-exit pane frozen for
|
|
259
|
+
// a done-node's manager handoff.)
|
|
91
260
|
if (row.intent === 'idle-release') {
|
|
92
261
|
unhealthySince.delete(id);
|
|
262
|
+
yieldStallSince.delete(id);
|
|
263
|
+
yieldTermAt.delete(id);
|
|
93
264
|
return;
|
|
94
265
|
}
|
|
95
266
|
const pid = row.pi_pid;
|
|
96
267
|
const piPidAlive = pid == null ? null : isPidAlive(pid);
|
|
97
268
|
if (piPidAlive !== false) {
|
|
98
269
|
unhealthySince.delete(id); // alive, or no pid to judge — nothing pending
|
|
270
|
+
if (piPidAlive === null) {
|
|
271
|
+
// No pid recorded: a legacy row, a relaunch in flight — or a spawn whose
|
|
272
|
+
// pi NEVER came up while its window lingers (Bug 4). Judge the last case.
|
|
273
|
+
handleNeverLaunched(row, now, revivedThisTick);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// pi ALIVE: judge a hung refresh-yield (Bug 1) — alive is not healthy
|
|
277
|
+
// when the node yielded, its turn ended, and the pi never exited.
|
|
278
|
+
handleYieldStall(row, pid, now);
|
|
279
|
+
}
|
|
99
280
|
return;
|
|
100
281
|
}
|
|
282
|
+
// pi is DEAD — any pending yield-stall clock is moot (the normal dead-pi
|
|
283
|
+
// grace path below owns recovery now).
|
|
284
|
+
yieldStallSince.delete(id);
|
|
285
|
+
yieldTermAt.delete(id);
|
|
101
286
|
const since = unhealthySince.get(id);
|
|
102
287
|
const verdict = livenessVerdict(piPidAlive, since === undefined ? null : now - since);
|
|
103
288
|
if (verdict === 'pending') {
|
|
@@ -111,8 +296,219 @@ function handleLiveWindow(row, now) {
|
|
|
111
296
|
// resumes the saved conversation. reviveNode opens a fresh window and clears
|
|
112
297
|
// pi_pid, so the next tick won't re-fire on this stale pid.
|
|
113
298
|
const resume = row.intent !== 'refresh';
|
|
114
|
-
|
|
299
|
+
// A broker has no pane — say so (review Mn-4); the tmux wording is unchanged.
|
|
300
|
+
const where = row.host_kind === 'broker' ? 'broker, no pane' : 'pane alive';
|
|
301
|
+
process.stderr.write(`[crtrd] revive ${id} (pi dead, ${where}, intent=${String(row.intent)})\n`);
|
|
115
302
|
reviveNode(id, { resume });
|
|
303
|
+
// Record for the third pass's bare double-spawn guard (Maj-4): this node's
|
|
304
|
+
// pi_pid is now NULL until the fresh pi re-records it, so a same-tick bare wake
|
|
305
|
+
// would otherwise read it as "not live" and double-spawn pi on the same .jsonl.
|
|
306
|
+
revivedThisTick.add(id);
|
|
307
|
+
}
|
|
308
|
+
/** A broker-hosted node has NO tmux pane — its only liveness signal is the
|
|
309
|
+
* recorded broker pid (the engine-container pid the stophook records on
|
|
310
|
+
* session_start, exactly like tmux pi). So the pane-gone user-close routing
|
|
311
|
+
* never applies: every unexpected death is a genuine crash. Reuses the EXISTING
|
|
312
|
+
* supervision primitive UNCHANGED — pid signal-0 + REVIVE_GRACE_MS grace +
|
|
313
|
+
* unhealthySince + revivedThisTick — NO new machinery (decision §1.7 / R3).
|
|
314
|
+
* Cases:
|
|
315
|
+
* • pid alive → leave; clear any stale grace timer.
|
|
316
|
+
* • pid null + pi_session_id set (a relaunch in flight — reviveNode clears
|
|
317
|
+
* pi_pid right after launch) → leave; the fresh broker re-records its pid.
|
|
318
|
+
* • pid null + pi_session_id null (NEVER booted) → normally the sub-second SDK
|
|
319
|
+
* boot gap, but a broker that throws BEFORE session_start records no pid and
|
|
320
|
+
* no session ever — so after a boot grace with STILL nothing, crash +
|
|
321
|
+
* surfaceBootFailure (the broker analog of the tmux never-booted path, M-1).
|
|
322
|
+
* • pid dead + intent==='refresh' → clean yield: respawn FRESH immediately, no
|
|
323
|
+
* grace wait (matches the tmux refresh path / design "as today").
|
|
324
|
+
* • pid dead + intent==='idle-release' → dormant by choice; leave, the second
|
|
325
|
+
* pass revives (resume) on the next unseen inbox entry.
|
|
326
|
+
* • pid dead + any other intent (crash / no clean intent) → grace-revive
|
|
327
|
+
* RESUME on the saved .jsonl via handleLiveWindow. Its internal resume is
|
|
328
|
+
* `row.intent !== 'refresh'`, which is true here (refresh + idle-release are
|
|
329
|
+
* already handled above), so it resumes — no explicit resume flag needed. */
|
|
330
|
+
async function handleBrokerLiveness(row, now, revivedThisTick) {
|
|
331
|
+
const id = row.node_id;
|
|
332
|
+
const pid = row.pi_pid;
|
|
333
|
+
// The broker engine is live → nothing pending; clear any boot/grace timer.
|
|
334
|
+
if (pid != null && isPidAlive(pid)) {
|
|
335
|
+
unhealthySince.delete(id);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (pid == null) {
|
|
339
|
+
// No supervised pid recorded. Two very different cases turn on pi_session_id:
|
|
340
|
+
// • a relaunch in flight — reviveNode clears pi_pid right after launch, but
|
|
341
|
+
// the node ALREADY booted once (pi_session_id captured), so the fresh
|
|
342
|
+
// broker re-records its pid within a tick or two; leave it.
|
|
343
|
+
// • a NEVER-BOOTED broker (pi_session_id null) — normally the sub-second SDK
|
|
344
|
+
// boot gap, BUT a broker that THROWS before session_start (malformed
|
|
345
|
+
// broker-launch.json, SessionManager.open on a missing .jsonl, a loader/
|
|
346
|
+
// registry/createAgentSession failure, the --fork / bare-id guards, or
|
|
347
|
+
// broker-cli's own fatal catch) records NO pid and NO session — EVER.
|
|
348
|
+
// With pid==null read unconditionally as "still booting" that strands the
|
|
349
|
+
// node 'active' with no engine forever and its parent waits on a dead
|
|
350
|
+
// child. Mirror the tmux never-booted path: after a boot grace with STILL
|
|
351
|
+
// no pid AND no session, crash + surfaceBootFailure up the spine (M-1).
|
|
352
|
+
const meta = getNode(id);
|
|
353
|
+
if (meta === null || meta.pi_session_id != null) {
|
|
354
|
+
unhealthySince.delete(id); // relaunch in flight (or identity already bound)
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const since = unhealthySince.get(id);
|
|
358
|
+
if (since === undefined) {
|
|
359
|
+
unhealthySince.set(id, now); // start the boot-grace clock
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (now - since < REVIVE_GRACE_MS)
|
|
363
|
+
return; // still inside the boot grace
|
|
364
|
+
// Boot grace elapsed, still no pid and no session → the broker never booted.
|
|
365
|
+
unhealthySince.delete(id);
|
|
366
|
+
process.stderr.write(`[crtrd] boot-failed ${id} (broker exited before session_start)\n`);
|
|
367
|
+
transition(id, 'crash');
|
|
368
|
+
try {
|
|
369
|
+
await surfaceBootFailure(meta);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
process.stderr.write(`[crtrd] surfaceBootFailure ${id} error: ${err.message}\n`);
|
|
373
|
+
}
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
// The broker pid is dead. Branch on intent.
|
|
377
|
+
if (row.intent === 'refresh') {
|
|
378
|
+
// Clean yield → respawn FRESH, immediately (no grace wait).
|
|
379
|
+
process.stderr.write(`[crtrd] revive ${id} (broker refresh-yield)\n`);
|
|
380
|
+
reviveNode(id, { resume: false });
|
|
381
|
+
revivedThisTick.add(id); // third-pass bare double-spawn guard (Maj-4)
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (row.intent === 'idle-release') {
|
|
385
|
+
// Freed itself while dormant → leave idle; the second pass revives it
|
|
386
|
+
// (resume) the moment its inbox gains an unseen entry.
|
|
387
|
+
unhealthySince.delete(id);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Any other intent → grace-revive RESUME, reusing handleLiveWindow's
|
|
391
|
+
// livenessVerdict → REVIVE_GRACE_MS → unhealthySince → revivedThisTick path
|
|
392
|
+
// unchanged (it re-probes the now-dead pid and accrues the grace window).
|
|
393
|
+
handleLiveWindow(row, now, revivedThisTick);
|
|
394
|
+
}
|
|
395
|
+
/** Fail loud for a drifted/broken wake (design §6.6/Q5): wake the ARMER DIRECTLY
|
|
396
|
+
* via appendInbox — NOT pushUrgent, which fans to subscribersOf(owner), and a
|
|
397
|
+
* DETACHED spawn has no subscribers (so the armer would never be reached); the
|
|
398
|
+
* literal {from:null} is also a type error for pushUrgent. Deduped per wakeup_id
|
|
399
|
+
* (notify once, suppress until the next success/cancel via notifiedWakeFailures)
|
|
400
|
+
* so a broken spawn-cron can't flood the unrotated inbox.jsonl. Falls back to a
|
|
401
|
+
* loud daemon-log line when the owner no longer resolves to a node. */
|
|
402
|
+
function failLoudWake(w, label, body) {
|
|
403
|
+
if (notifiedWakeFailures.has(w.wakeup_id))
|
|
404
|
+
return;
|
|
405
|
+
notifiedWakeFailures.add(w.wakeup_id);
|
|
406
|
+
if (getRow(w.owner_id) !== null) {
|
|
407
|
+
appendInbox(w.owner_id, {
|
|
408
|
+
from: null,
|
|
409
|
+
tier: 'urgent',
|
|
410
|
+
kind: 'urgent',
|
|
411
|
+
label,
|
|
412
|
+
data: { body },
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
process.stderr.write(`[crtrd] wake ${w.wakeup_id} failed (owner ${w.owner_id} gone): ${body}\n`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
// How long a dead node's on-disk record must have been QUIET (no meta/telemetry
|
|
420
|
+
// write) before its leftover placement is reaped. A fresh crash keeps its pane
|
|
421
|
+
// around this long so a human can read the corpse; a long-dead node's orphaned
|
|
422
|
+
// pane is torn down on sight.
|
|
423
|
+
export const DEAD_REAP_GRACE_MS = 10 * 60_000;
|
|
424
|
+
/** ms since the node's durable record was last written (meta.json / job/
|
|
425
|
+
* telemetry.json mtimes — the files every live cycle touches). Infinity when
|
|
426
|
+
* neither exists: no disk evidence reads as ancient, not fresh. Deliberately
|
|
427
|
+
* NOT inbox.jsonl — children keep appending to a dead manager's inbox, which
|
|
428
|
+
* must not hold its corpse un-reapable forever. */
|
|
429
|
+
function msSinceDiskActivity(nodeId, now) {
|
|
430
|
+
let latest = 0;
|
|
431
|
+
for (const p of [join(nodeDir(nodeId), 'meta.json'), join(jobDir(nodeId), 'telemetry.json')]) {
|
|
432
|
+
try {
|
|
433
|
+
const m = statSync(p).mtimeMs;
|
|
434
|
+
if (m > latest)
|
|
435
|
+
latest = m;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
/* absent — contributes nothing */
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return latest === 0 ? Number.POSITIVE_INFINITY : now - latest;
|
|
442
|
+
}
|
|
443
|
+
/** Reap the leftover PLACEMENT of long-dead nodes (audit 2026-06-09, Bug 3 —
|
|
444
|
+
* node mq3l84it-40f6e8ac, a dead resident whose orphaned pane + focus row sat
|
|
445
|
+
* untouched for ~36h). The daemon supervises only active|idle rows, so a node
|
|
446
|
+
* that lands `dead` while still holding a pane is invisible to every pass and
|
|
447
|
+
* its residue leaks forever.
|
|
448
|
+
*
|
|
449
|
+
* Deliberate REAP-not-REVIVE decision: a dead node is NOT auto-revived. `dead`
|
|
450
|
+
* means crashed — auto-resurrecting a crashed RESIDENT (a human conversation)
|
|
451
|
+
* would pop dead chats back open unbidden, and the daemon's contract is to
|
|
452
|
+
* supervise live nodes, not to undo terminal states. A dead resident's wake
|
|
453
|
+
* sources remain the human ones (`node msg --tier critical`, focus, `canvas
|
|
454
|
+
* revive`), all of which still work after this reap. What we DO reclaim is the
|
|
455
|
+
* physical residue: kill the orphaned pane, drop the focus row, null the
|
|
456
|
+
* LOCATION (tearDownNode). The node row, its edges, and all on-disk state stay
|
|
457
|
+
* intact, so a later manual revive keeps its full graph — the leak was the
|
|
458
|
+
* pane/focus/presence, not the row. Grace: only after DEAD_REAP_GRACE_MS of
|
|
459
|
+
* disk quiet, so a just-crashed pane survives long enough to be inspected. */
|
|
460
|
+
function reapDeadResidue(now) {
|
|
461
|
+
let rows;
|
|
462
|
+
try {
|
|
463
|
+
rows = listNodes({ status: ['dead'] });
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
process.stderr.write(`[crtrd] reapDeadResidue list error: ${err.message}\n`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
for (const row of rows) {
|
|
470
|
+
try {
|
|
471
|
+
if (row.pane == null && row.window == null && row.tmux_session == null)
|
|
472
|
+
continue;
|
|
473
|
+
if (msSinceDiskActivity(row.node_id, now) < DEAD_REAP_GRACE_MS)
|
|
474
|
+
continue;
|
|
475
|
+
process.stderr.write(`[crtrd] reap ${row.node_id} (dead, placement leaked: pane=${String(row.pane)} window=${String(row.window)})\n`);
|
|
476
|
+
tearDownNode(row.node_id);
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
process.stderr.write(`[crtrd] error reaping ${row.node_id}: ${err.message}\n`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** GC stale focus rows (audit 2026-06-09, Bug 5): nothing else deletes a focuses
|
|
484
|
+
* row when its pane dies outside the sanctioned teardown paths (a user
|
|
485
|
+
* kill-pane, a tmux server restart, a crashed swap), so dead-pane rows
|
|
486
|
+
* accumulate forever — the audit found 15 of 21 rows pointing at gone panes,
|
|
487
|
+
* and a stale row both lies to graphSurfaceTarget and blocks its node from
|
|
488
|
+
* being adopted into a fresh viewport (UNIQUE node_id). Sweep them here: one
|
|
489
|
+
* batched `list-panes -a` probe (never a per-row display-message), deleting
|
|
490
|
+
* rows whose recorded pane no longer exists. A pane-less row (a bridge/unplaced
|
|
491
|
+
* viewport) has nothing to verify and is left alone. On a FAILED probe (tmux
|
|
492
|
+
* unreachable — daemon racing a server restart) we skip the sweep entirely:
|
|
493
|
+
* "can't tell" must never mass-delete every viewport. */
|
|
494
|
+
function gcStaleFocuses() {
|
|
495
|
+
try {
|
|
496
|
+
const rows = listFocuses();
|
|
497
|
+
if (rows.length === 0)
|
|
498
|
+
return;
|
|
499
|
+
const live = listLivePanes();
|
|
500
|
+
if (live === null)
|
|
501
|
+
return; // probe failed — never GC on "can't tell"
|
|
502
|
+
for (const f of rows) {
|
|
503
|
+
if (f.pane === null || live.has(f.pane))
|
|
504
|
+
continue;
|
|
505
|
+
process.stderr.write(`[crtrd] gc focus ${f.focus_id} (pane ${f.pane} gone, occupant ${f.node_id})\n`);
|
|
506
|
+
closeFocusRow(f.focus_id);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
process.stderr.write(`[crtrd] gcStaleFocuses error: ${err.message}\n`);
|
|
511
|
+
}
|
|
116
512
|
}
|
|
117
513
|
// ---------------------------------------------------------------------------
|
|
118
514
|
// Pidfile
|
|
@@ -142,18 +538,11 @@ export function readPidfile() {
|
|
|
142
538
|
const n = Number(raw);
|
|
143
539
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
144
540
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
process.kill(pid, 0);
|
|
151
|
-
return true;
|
|
152
|
-
}
|
|
153
|
-
catch (e) {
|
|
154
|
-
return e.code === 'EPERM';
|
|
155
|
-
}
|
|
156
|
-
}
|
|
541
|
+
// isPidAlive now lives in canvas/pid.ts (the one shared signal-0 probe at the
|
|
542
|
+
// lowest layer, so canvas/, runtime/, AND daemon/ all import it down). Re-
|
|
543
|
+
// exported here to preserve the public surface consumed by commands/daemon.ts
|
|
544
|
+
// and the grace-clock test.
|
|
545
|
+
export { isPidAlive };
|
|
157
546
|
/** True when a crtrd process is already running (pidfile exists + pid alive). */
|
|
158
547
|
export function isDaemonRunning() {
|
|
159
548
|
const pid = readPidfile();
|
|
@@ -171,8 +560,22 @@ export async function superviseTick(now = Date.now()) {
|
|
|
171
560
|
process.stderr.write(`[crtrd] listNodes error: ${err.message}\n`);
|
|
172
561
|
return;
|
|
173
562
|
}
|
|
563
|
+
// Node-ids revived in pass 1 + pass 2 THIS tick — the third pass's bare branch
|
|
564
|
+
// skips them so it never launches a second pi on the same .jsonl (Maj-4).
|
|
565
|
+
const revivedThisTick = new Set();
|
|
174
566
|
for (const row of rows) {
|
|
175
567
|
try {
|
|
568
|
+
// Broker-hosted node: it has NO tmux placement (tmux_session/window/pane
|
|
569
|
+
// are all NULL) but IS daemon-supervised via its recorded broker pid.
|
|
570
|
+
// Route it here BEFORE the inline-root carve-out below (which would
|
|
571
|
+
// wrongly skip a broker on the all-NULL placement) AND before the
|
|
572
|
+
// pane-gone block, whose first act (unhealthySince.delete) would wipe the
|
|
573
|
+
// broker's grace timer every tick. This single placement does both jobs
|
|
574
|
+
// (decision §1.6) — no edit to the carve-out is needed.
|
|
575
|
+
if (row.host_kind === 'broker') {
|
|
576
|
+
await handleBrokerLiveness(row, now, revivedThisTick);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
176
579
|
// Runtime (tmux_session, window, intent, pi_pid) is now authoritative IN
|
|
177
580
|
// the row — no per-node getNode re-read. Only the boot-failure split below
|
|
178
581
|
// still needs identity (pi_session_id), read on demand there.
|
|
@@ -181,23 +584,46 @@ export async function superviseTick(now = Date.now()) {
|
|
|
181
584
|
// even when its derived window/session cache is null.
|
|
182
585
|
if (row.tmux_session == null && row.window == null && row.pane == null)
|
|
183
586
|
continue;
|
|
184
|
-
if (
|
|
587
|
+
if (hostFor(row).isAlive(row)) {
|
|
185
588
|
// The pane is up — but that alone doesn't mean pi is. Reconcile first
|
|
186
589
|
// (follow any manual pane move, and lazy-backfill a legacy row's pane
|
|
187
590
|
// from its live window), then judge pi liveness off the fresh row. The
|
|
188
591
|
// alive-gate means reconcile here only ever FOLLOWS/backfills — never
|
|
189
592
|
// nulls the LOCATION out from under the gone-branches below.
|
|
190
593
|
reconcile(row.node_id);
|
|
191
|
-
handleLiveWindow(getRow(row.node_id) ?? row, now);
|
|
594
|
+
handleLiveWindow(getRow(row.node_id) ?? row, now, revivedThisTick);
|
|
192
595
|
continue;
|
|
193
596
|
}
|
|
194
597
|
// The pane is gone. Branch on why.
|
|
195
598
|
unhealthySince.delete(row.node_id); // pane-gone path owns it now
|
|
599
|
+
yieldStallSince.delete(row.node_id);
|
|
600
|
+
yieldTermAt.delete(row.node_id);
|
|
601
|
+
// Zombie vehicle (audit 2026-06-09, Bug 2 — node mq4ms6n4-fbc455cd): the
|
|
602
|
+
// pane died but its pi SURVIVED (pi can outlive the pane's SIGHUP). Every
|
|
603
|
+
// branch below assumes "pane gone ⇒ pi gone"; with a live orphan that
|
|
604
|
+
// assumption corrupts — the headless pi keeps re-arming its inbox watcher
|
|
605
|
+
// with no pane a human can reach, the second pass's pi-liveness gate skips
|
|
606
|
+
// a released node forever, and a refresh-revive would run a second pi
|
|
607
|
+
// beside it. There is no legitimate pane-gone+pi-alive state (in-place
|
|
608
|
+
// respawns keep the pane id; teardown paths null presence before reaping),
|
|
609
|
+
// so kill the orphan first, then route normally. SIGKILL, not SIGTERM: an
|
|
610
|
+
// orphaned pi's shutdown hooks mutating canvas state mid-routing is
|
|
611
|
+
// exactly what we're fencing out.
|
|
612
|
+
if (row.pi_pid != null && isPidAlive(row.pi_pid)) {
|
|
613
|
+
process.stderr.write(`[crtrd] kill ${row.node_id} (zombie pi ${row.pi_pid}: pane gone, pi alive)\n`);
|
|
614
|
+
try {
|
|
615
|
+
process.kill(row.pi_pid, 'SIGKILL');
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
/* raced its own death — fine */
|
|
619
|
+
}
|
|
620
|
+
}
|
|
196
621
|
if (row.intent === 'refresh') {
|
|
197
622
|
// The node set intent=refresh before stopping — a clean yield. Respawn
|
|
198
623
|
// fresh so it re-reads its roadmap/context dir.
|
|
199
624
|
process.stderr.write(`[crtrd] revive ${row.node_id} (refresh-yield)\n`);
|
|
200
625
|
reviveNode(row.node_id, { resume: false });
|
|
626
|
+
revivedThisTick.add(row.node_id); // third-pass bare double-spawn guard (Maj-4)
|
|
201
627
|
}
|
|
202
628
|
else if (row.intent === 'idle-release') {
|
|
203
629
|
// The node freed its own window on purpose while dormant. Drop the stale
|
|
@@ -206,8 +632,11 @@ export async function superviseTick(now = Date.now()) {
|
|
|
206
632
|
setPresence(row.node_id, { tmux_session: row.tmux_session, window: null });
|
|
207
633
|
}
|
|
208
634
|
else {
|
|
209
|
-
// The pane vanished without the node yielding or releasing
|
|
210
|
-
// the
|
|
635
|
+
// The pane vanished without the node yielding or releasing — most often
|
|
636
|
+
// the user CLOSED it (kill-pane/kill-window), which crtr cannot tell apart
|
|
637
|
+
// from a window death. Closing a pane is a benign "get it off my screen",
|
|
638
|
+
// NOT an orphan-the-work kill, so route on what the node was DOING and keep
|
|
639
|
+
// anything with outstanding work REVIVABLE:
|
|
211
640
|
// • never-booted (pi_session_id null) → crash + surface boot failure.
|
|
212
641
|
// A spawn failure the parent was never told about — it had no turn to
|
|
213
642
|
// finish, so it can never be a finalize. (Boot-failed vs crashed turns
|
|
@@ -218,18 +647,47 @@ export async function superviseTick(now = Date.now()) {
|
|
|
218
647
|
// killed inside a turn: a genuine mid-run death. (The pane is gone, so
|
|
219
648
|
// pi is dead; we read isBusy WITHOUT the usual AND-pidAlive guard on
|
|
220
649
|
// purpose — here a stale marker IS the proof it died mid-turn.)
|
|
221
|
-
// • finished its turn (busy ABSENT) but
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
650
|
+
// • finished its turn (busy ABSENT) but STILL WAITING — awaiting a LIVE
|
|
651
|
+
// child OR holding a pending self-wake → RELEASE (→idle + idle-release),
|
|
652
|
+
// NOT dead, and NO parent wake. This is HEALTHY DORMANCY (the same
|
|
653
|
+
// boundary the stop-guard draws): real orchestration / a scheduled
|
|
654
|
+
// clock is outstanding; killing it would orphan in-flight work, and
|
|
655
|
+
// waking its parent here would re-create the spurious-wake storm the
|
|
656
|
+
// wake doctrine exists to kill. Drop the stale window; the second /
|
|
657
|
+
// wakeups pass revives it on the next child push or clock fire.
|
|
658
|
+
// • finished its turn AND nothing live to wait for AND no pending clock →
|
|
659
|
+
// finalize (→done): it did its own work and the pane was closed to
|
|
660
|
+
// dismiss it. GENUINE death → wake the inbox-waiting parent.
|
|
226
661
|
const meta = getNode(row.node_id);
|
|
227
662
|
const neverBooted = meta !== null && meta.pi_session_id == null;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
663
|
+
const finishedTurn = !neverBooted && !isBusy(row.node_id);
|
|
664
|
+
// The death-vs-dormancy boundary, drawn EXACTLY where the stop-guard draws
|
|
665
|
+
// it (stop-guard.ts): a live active subscription OR a pending self-wake is
|
|
666
|
+
// a legitimate wait. Only a finished node with NEITHER is genuinely done.
|
|
667
|
+
const stillWaiting = hasActiveLiveSubscription(row.node_id) || hasPendingSelfWake(row.node_id);
|
|
668
|
+
if (finishedTurn && !stillWaiting) {
|
|
231
669
|
transition(row.node_id, 'finalize');
|
|
232
|
-
process.stderr.write(`[crtrd] done ${row.node_id} (pane gone after turn end,
|
|
670
|
+
process.stderr.write(`[crtrd] done ${row.node_id} (pane gone after turn end, nothing live to wait for)\n`);
|
|
671
|
+
// Wake the inbox-waiting parent: the child ended without a `push final`
|
|
672
|
+
// and was dismissed; without this the daemon-finalize reaches no one
|
|
673
|
+
// (D-1) and a purely-inbox-waiting parent hangs forever.
|
|
674
|
+
if (meta !== null) {
|
|
675
|
+
try {
|
|
676
|
+
await surfaceChildDeath(meta, 'finalize');
|
|
677
|
+
}
|
|
678
|
+
catch (err) {
|
|
679
|
+
process.stderr.write(`[crtrd] surfaceChildDeath(finalize) ${row.node_id} error: ${err.message}\n`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
else if (finishedTurn) {
|
|
684
|
+
// Still legitimately waiting (a live child OR a pending self-wake) →
|
|
685
|
+
// revivable, NOT a death: closing the pane must not orphan in-flight
|
|
686
|
+
// work, and healthy dormancy must NOT wake the parent. Clear the stale
|
|
687
|
+
// window; the second / wakeups pass revives it.
|
|
688
|
+
setPresence(row.node_id, { tmux_session: row.tmux_session, window: null, pane: null });
|
|
689
|
+
transition(row.node_id, 'release');
|
|
690
|
+
process.stderr.write(`[crtrd] release ${row.node_id} (pane gone while still waiting → revivable, no parent wake)\n`);
|
|
233
691
|
}
|
|
234
692
|
else {
|
|
235
693
|
transition(row.node_id, 'crash');
|
|
@@ -243,7 +701,18 @@ export async function superviseTick(now = Date.now()) {
|
|
|
243
701
|
}
|
|
244
702
|
}
|
|
245
703
|
else {
|
|
246
|
-
process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone, intent=${String(row.intent)})\n`);
|
|
704
|
+
process.stderr.write(`[crtrd] dead ${row.node_id} (pane gone mid-generation, intent=${String(row.intent)})\n`);
|
|
705
|
+
// Wake the inbox-waiting parent on a GENUINE mid-run death (D-1): a
|
|
706
|
+
// booted child whose pane died inside a turn is unambiguously dead
|
|
707
|
+
// (busy marker present), never healthy dormancy.
|
|
708
|
+
if (meta !== null) {
|
|
709
|
+
try {
|
|
710
|
+
await surfaceChildDeath(meta, 'crash');
|
|
711
|
+
}
|
|
712
|
+
catch (err) {
|
|
713
|
+
process.stderr.write(`[crtrd] surfaceChildDeath(crash) ${row.node_id} error: ${err.message}\n`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
247
716
|
}
|
|
248
717
|
}
|
|
249
718
|
}
|
|
@@ -267,21 +736,168 @@ export async function superviseTick(now = Date.now()) {
|
|
|
267
736
|
if (r.status !== 'idle' || r.intent !== 'idle-release')
|
|
268
737
|
continue;
|
|
269
738
|
// The in-process inbox-watcher only owns delivery while pi is actually LIVE.
|
|
270
|
-
// A
|
|
271
|
-
//
|
|
272
|
-
//
|
|
739
|
+
// A released node is pi-DEAD with no watcher — whether its pane is GONE
|
|
740
|
+
// (unfocused release) or still ALIVE (a remain-on-exit pane frozen for a
|
|
741
|
+
// done-node's manager handoff) — so the daemon must wake it. Gate the skip
|
|
742
|
+
// on pi liveness, NOT pane presence (which would skip a frozen pane
|
|
743
|
+
// forever, §3c).
|
|
273
744
|
if (r.pi_pid != null && isPidAlive(r.pi_pid))
|
|
274
745
|
continue;
|
|
275
746
|
const entries = readInboxSince(row.node_id, readCursor(row.node_id));
|
|
276
747
|
if (entries.length > 0) {
|
|
277
748
|
process.stderr.write(`[crtrd] revive ${row.node_id} (idle-release, inbox)\n`);
|
|
278
749
|
reviveNode(row.node_id, { resume: true });
|
|
750
|
+
revivedThisTick.add(row.node_id); // third-pass bare double-spawn guard (Maj-4)
|
|
279
751
|
}
|
|
280
752
|
}
|
|
281
753
|
catch (err) {
|
|
282
754
|
process.stderr.write(`[crtrd] error polling inbox ${row.node_id}: ${err.message}\n`);
|
|
283
755
|
}
|
|
284
756
|
}
|
|
757
|
+
// Third pass: FIRE DUE SCHEDULED WAKES (the wakeups engine, migration v7).
|
|
758
|
+
// Runs immediately AFTER the inbox second pass — the ordering is load-bearing
|
|
759
|
+
// (design §6.4): a same-tick inbox revive runs cancelDeadlinesFor (inside
|
|
760
|
+
// reviveNode) BEFORE this pass's dueWakes query, so an event that wins a
|
|
761
|
+
// deadline deletes its row before it can fire. The pass-2 inbox loop above is
|
|
762
|
+
// otherwise untouched (design D3) — it stays the generic wake-on-message path
|
|
763
|
+
// that noted/deadline rely on; the only addition is the revivedThisTick.add
|
|
764
|
+
// observation that the bare branch below needs to avoid a double-spawn.
|
|
765
|
+
// For each due row, in fire_at order, settle the row FIRST (advance-or-consume
|
|
766
|
+
// — crash-safety, design D4) THEN enact by kind, wrapped per-row so one bad
|
|
767
|
+
// wake never kills the tick.
|
|
768
|
+
const nowIso = new Date(now).toISOString();
|
|
769
|
+
const nowDate = new Date(now);
|
|
770
|
+
let due;
|
|
771
|
+
try {
|
|
772
|
+
due = dueWakes(nowIso);
|
|
773
|
+
}
|
|
774
|
+
catch (err) {
|
|
775
|
+
process.stderr.write(`[crtrd] dueWakes error: ${err.message}\n`);
|
|
776
|
+
due = [];
|
|
777
|
+
}
|
|
778
|
+
for (const w of due) {
|
|
779
|
+
try {
|
|
780
|
+
// 1. Advance-or-consume FIRST (design D4): settle the row's pending state
|
|
781
|
+
// before ANY side effect, so a daemon crash mid-fire can never double-
|
|
782
|
+
// fire (a double SPAWN is the worst case — structurally prevented here).
|
|
783
|
+
if (w.recur == null) {
|
|
784
|
+
consumeWake(w.wakeup_id);
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
let nextFire;
|
|
788
|
+
try {
|
|
789
|
+
nextFire = nextSlotAfter(w.recur, nowDate);
|
|
790
|
+
}
|
|
791
|
+
catch (err) {
|
|
792
|
+
// The recur is unparseable (parseCadence validates at arm time, so this
|
|
793
|
+
// is a rare backstop — a corrupted/foreign row). Leaving a past-fire_at
|
|
794
|
+
// row pending would re-qualify it EVERY tick (silent CPU spin, enact
|
|
795
|
+
// never reached), so quarantine it (consume) + fail loud ONCE; never
|
|
796
|
+
// re-query it each tick (Min-8).
|
|
797
|
+
consumeWake(w.wakeup_id);
|
|
798
|
+
failLoudWake(w, '⚠ recurrence quarantined', `Scheduled wake ${w.wakeup_id} has an unparseable recurrence and was removed: ` +
|
|
799
|
+
`${err.message}\n\nRe-arm it with a valid cadence.`);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
advanceWake(w.wakeup_id, nextFire);
|
|
803
|
+
}
|
|
804
|
+
// 2. Enact by kind.
|
|
805
|
+
if (w.kind === 'bare') {
|
|
806
|
+
// A bare wake leaves NO inbox entry, so pass 2 can never wake it — it
|
|
807
|
+
// must revive DIRECTLY with resume:false (re-reads roadmap; AC-N2).
|
|
808
|
+
const target = w.node_id == null ? null : getRow(w.node_id);
|
|
809
|
+
if (target === null)
|
|
810
|
+
continue; // target gone (the FK cascade reaped it)
|
|
811
|
+
// Same-tick double-spawn guard (Maj-4): pass 1/2 (or an earlier bare row
|
|
812
|
+
// this pass) may have revived this node THIS tick — reviveNode clears
|
|
813
|
+
// pi_pid to NULL before the fresh pi re-records it, so the pi_pid==null
|
|
814
|
+
// liveness check below would falsely read "not live" and a second
|
|
815
|
+
// reviveNode would launch a SECOND pi on the same .jsonl (corruption).
|
|
816
|
+
// (Pane-existence is the WRONG guard — it would no-op a frozen-focus bare
|
|
817
|
+
// wake, which is pane-alive/pi-dead by design.)
|
|
818
|
+
if (revivedThisTick.has(target.node_id))
|
|
819
|
+
continue;
|
|
820
|
+
// pi live → no-op (AC-E3 bare): an already-awake node needs no revive.
|
|
821
|
+
if (target.pi_pid != null && isPidAlive(target.pi_pid))
|
|
822
|
+
continue;
|
|
823
|
+
process.stderr.write(`[crtrd] wake ${target.node_id} (bare alarm)\n`);
|
|
824
|
+
// wakeReason carries the timer provenance so the fresh-revive kickoff
|
|
825
|
+
// leads with a <crtr-wake> block (Invariant B/D: the node learns a clock,
|
|
826
|
+
// not an event, woke it) instead of a generic context-refresh framing.
|
|
827
|
+
reviveNode(target.node_id, { resume: false, wakeReason: wakeOriginFrom(w) });
|
|
828
|
+
revivedThisTick.add(target.node_id);
|
|
829
|
+
}
|
|
830
|
+
else if (w.kind === 'noted') {
|
|
831
|
+
// Deliver the note; the UNMODIFIED pass 2 wakes the dormant target next
|
|
832
|
+
// tick (AC-N1), or its live in-process watcher delivers it (AC-E3 noted).
|
|
833
|
+
if (w.node_id == null)
|
|
834
|
+
continue;
|
|
835
|
+
const p = w.payload;
|
|
836
|
+
// Mark the delivery as a SCHEDULED wake (Invariant D): the woken node sees
|
|
837
|
+
// only the rendered digest (inbox inlines label/body, never arbitrary
|
|
838
|
+
// data keys), so the timer signal MUST ride the visible label — the same
|
|
839
|
+
// ⏰ family deadline uses — so a noted wake is distinguishable from a plain
|
|
840
|
+
// `node msg`, not indistinguishable from it.
|
|
841
|
+
appendInbox(w.node_id, {
|
|
842
|
+
from: w.owner_id ?? null,
|
|
843
|
+
tier: 'normal',
|
|
844
|
+
kind: 'message',
|
|
845
|
+
label: `⏰ scheduled wake — ${p.label}`,
|
|
846
|
+
data: { body: p.body },
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
else if (w.kind === 'deadline') {
|
|
850
|
+
// Same delivery as noted but URGENT tier. The woken node sees only the
|
|
851
|
+
// RENDERED digest (inbox inlines label/body, NEVER arbitrary data keys),
|
|
852
|
+
// so the timeout signal MUST ride the visible label/body (Maj-8);
|
|
853
|
+
// data.timeout is a machine-readable mirror only. body is always non-
|
|
854
|
+
// empty (the surface supplies a default timeout note when none is
|
|
855
|
+
// authored, Maj-7). One-shot — already consumed in step 1.
|
|
856
|
+
if (w.node_id == null)
|
|
857
|
+
continue;
|
|
858
|
+
const p = w.payload;
|
|
859
|
+
appendInbox(w.node_id, {
|
|
860
|
+
from: w.owner_id ?? null,
|
|
861
|
+
tier: 'urgent',
|
|
862
|
+
kind: 'message',
|
|
863
|
+
label: `⏰ deadline reached — ${p.label}`,
|
|
864
|
+
data: { body: p.body, timeout: true },
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
else if (w.kind === 'spawn') {
|
|
868
|
+
// Re-derives LaunchSpec live (AC-W2); payload.parent is the non-null
|
|
869
|
+
// resolved armer id (T2/T7 guarantee it), so spawnChild never throws the
|
|
870
|
+
// "requires a calling node" error even for a --root recipe.
|
|
871
|
+
const recipe = w.payload;
|
|
872
|
+
try {
|
|
873
|
+
// Spread in the wake provenance at fire time (in-memory only; never the
|
|
874
|
+
// stored recipe) so the born node's kickoff leads with a <crtr-wake>
|
|
875
|
+
// block — it learns, by construction, that a timer birthed it (and, for
|
|
876
|
+
// a spawn-cron, that it is one run of a standing job).
|
|
877
|
+
spawnChild({ ...recipe, wakeOrigin: wakeOriginFrom(w) });
|
|
878
|
+
notifiedWakeFailures.delete(w.wakeup_id); // success clears the dedup latch
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
// Fail loud by waking the ARMER DIRECTLY (design §6.6/Q5). The row was
|
|
882
|
+
// already settled in step 1, so this is never a hot retry loop; a
|
|
883
|
+
// permanently-broken spawn-cron stays deduped-noisy until cancel.
|
|
884
|
+
failLoudWake(w, '⚠ deferred spawn failed', `Deferred spawn (${recipe.kind} @ ${recipe.cwd}) failed: ` +
|
|
885
|
+
`${err.message}\n\nRe-home (fix the cwd/parent) or re-arm the wake.`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
catch (err) {
|
|
890
|
+
// One bad wake never kills the tick.
|
|
891
|
+
process.stderr.write(`[crtrd] error firing wake ${w.wakeup_id}: ${err.message}\n`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
// Fourth pass: reap the leftover placement of long-dead nodes (Bug 3), then
|
|
895
|
+
// sweep focus rows whose pane died outside the sanctioned teardown paths
|
|
896
|
+
// (Bug 5). Reap first so the GC also catches any focus row a reaped pane
|
|
897
|
+
// strands; last overall so neither races a focus a same-tick revive just
|
|
898
|
+
// re-anchored.
|
|
899
|
+
reapDeadResidue(now);
|
|
900
|
+
gcStaleFocuses();
|
|
285
901
|
}
|
|
286
902
|
/** Start the supervisor loop.
|
|
287
903
|
*
|