@crouton-kit/crouter 0.3.39 → 0.3.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +706 -733
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
- package/dist/core/hearth/providers/blaxel-home.js +0 -386
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
|
@@ -6,5 +6,159 @@ export declare function isPidAlive(pid: number | null | undefined): boolean;
|
|
|
6
6
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
|
7
7
|
* an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
|
|
8
8
|
* timeout + stale-lease recovery kills). Lives beside isPidAlive: canvas/ is
|
|
9
|
-
* the lowest shared layer every process-liveness/-teardown primitive sits at.
|
|
9
|
+
* the lowest shared layer every process-liveness/-teardown primitive sits at.
|
|
10
|
+
* Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
|
|
11
|
+
* (which means "my own group") or a negative/non-integer value. */
|
|
10
12
|
export declare function killProcessGroup(pid: number, signal?: NodeJS.Signals): void;
|
|
13
|
+
/** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
|
|
14
|
+
* pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
|
|
15
|
+
* crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with
|
|
16
|
+
* `detached: true` (verified in `@earendil-works/pi-agent-core`'s
|
|
17
|
+
* `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a
|
|
18
|
+
* brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it.
|
|
19
|
+
* `setsid()` changes only SID/PGID, NEVER ppid, so the child (and its own
|
|
20
|
+
* descendants) is still discoverable by walking parentage from `rootPid`, as
|
|
21
|
+
* long as `rootPid`'s OS process is still alive when we walk (a fully-dead
|
|
22
|
+
* parent is reparented away by the kernel immediately on exit, severing this
|
|
23
|
+
* link — the wedged-but-alive broker this fix targets never hits that). Best-
|
|
24
|
+
* effort: returns `[]` (never throws) on any `ps` failure, exactly like
|
|
25
|
+
* `killProcessGroup`'s ESRCH-swallowing. */
|
|
26
|
+
export declare function descendantPids(rootPid: number): number[];
|
|
27
|
+
/** Capture a portable process-identity fingerprint — `ps`'s `lstart` column,
|
|
28
|
+
* the process's full wall-clock start time — for each of `pids`, in ONE
|
|
29
|
+
* batched `ps -p <list>` call. `lstart` is a standard format keyword on BOTH
|
|
30
|
+
* BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or
|
|
31
|
+
* other Linux-only machinery. Used to guard against PID REUSE during the
|
|
32
|
+
* multi-second teardown escalation ladder (crouter#98 review finding 3): a
|
|
33
|
+
* captured pid can exit and the OS can recycle it for an unrelated process
|
|
34
|
+
* before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)`
|
|
35
|
+
* cannot tell the difference.
|
|
36
|
+
*
|
|
37
|
+
* Returns `null` — NOT an empty map — when the `ps` PROBE ITSELF failed (spawn
|
|
38
|
+
* error, non-zero exit, non-string stdout): a probe failure means we have NO
|
|
39
|
+
* information, which is a fundamentally different outcome from a probe that
|
|
40
|
+
* SUCCEEDED and simply found no row for a given pid (that pid is genuinely
|
|
41
|
+
* gone). Collapsing these two into the same empty map was the final-review
|
|
42
|
+
* bug (crouter#98 signal-safety finding): `killProcessTreePids` cannot tell
|
|
43
|
+
* "pid confirmed gone" from "we don't know" without this distinction, and
|
|
44
|
+
* misreading a failed probe as positive proof of absence/reuse either signals
|
|
45
|
+
* a reused stranger or — worse — silently skips reaping a real live orphan.
|
|
46
|
+
* A successful call may still return a map with no entry for some/all of
|
|
47
|
+
* `pids` (those pids have no current row — genuinely gone); that is the
|
|
48
|
+
* valid empty-or-partial case and is NOT a failure. */
|
|
49
|
+
export declare function capturePidIdentities(pids: readonly number[]): Map<number, string> | null;
|
|
50
|
+
/** The full pre-signal snapshot `teardown()` needs, taken ONCE, from ONE `ps`
|
|
51
|
+
* table (crouter#98 final review, Fix 1): the process TREE rooted at `rootPid`
|
|
52
|
+
* (itself + every transitive descendant) alongside each member's identity
|
|
53
|
+
* fingerprint — PLUS, load-bearing, a launch-time-identity REUSE check on
|
|
54
|
+
* `rootPid` itself before the tree is even walked.
|
|
55
|
+
*
|
|
56
|
+
* The hazard this closes: the PID-reuse guard elsewhere in this module (see
|
|
57
|
+
* `killProcessTreePids`) only ever compared identities captured across the
|
|
58
|
+
* teardown ESCALATION window (a few seconds) — it had no idea whether
|
|
59
|
+
* `rootPid` (the broker pid recorded on the node row at LAUNCH time, possibly
|
|
60
|
+
* hours or days earlier) had ALREADY been recycled for an unrelated process
|
|
61
|
+
* before `teardown()` was ever called. In that case the naive snapshot would
|
|
62
|
+
* capture the STRANGER's current identity as the "expected" baseline and
|
|
63
|
+
* cheerfully SIGTERM/SIGKILL it and whatever the stranger's OWN process tree
|
|
64
|
+
* turns out to be — exactly the wrong-victim risk finding 1 targets.
|
|
65
|
+
*
|
|
66
|
+
* `expectedIdentity` is the LAUNCH-time identity recorded on the node row
|
|
67
|
+
* (`recordPid`'s `capturePidIdentities([pid])` snapshot, taken the instant the
|
|
68
|
+
* broker was spawned/bound) — `null` for a node booted before this field
|
|
69
|
+
* existed, or if that original capture itself failed. When `expectedIdentity`
|
|
70
|
+
* is present AND the CURRENT probe for `rootPid` succeeds AND finds a DIFFERENT
|
|
71
|
+
* identity, `rootPid` is provably a different OS process than the broker this
|
|
72
|
+
* node ever launched: `reused: true`, `tree: []`, `identities: null` — the
|
|
73
|
+
* caller must treat this exactly like "nothing to tear down", and must NOT
|
|
74
|
+
* walk `descendantPids` from this poisoned root (those "descendants" would be
|
|
75
|
+
* the STRANGER's children, not this node's). Every other case (no baseline,
|
|
76
|
+
* the pid is simply gone, the probe itself failed) falls through to the
|
|
77
|
+
* normal walk — fail-open, matching every other guard in this module: a
|
|
78
|
+
* MISMATCH is the only thing that skips, never an absence of evidence. */
|
|
79
|
+
export interface TeardownSnapshot {
|
|
80
|
+
/** `[rootPid, ...descendants]`, or `[]` when `reused` is true (nothing safe
|
|
81
|
+
* to signal) or `rootPid` itself is invalid. */
|
|
82
|
+
tree: number[];
|
|
83
|
+
/** Identity fingerprint per pid in `tree`, from the SAME `ps` table read as
|
|
84
|
+
* the tree walk — or `null` when that probe failed outright (fail-open: the
|
|
85
|
+
* caller's downstream `killProcessTreePids` signals unguarded in that case,
|
|
86
|
+
* same as an omitted `identities` arg). Always `null` when `reused` is true
|
|
87
|
+
* (there is no tree to guard). */
|
|
88
|
+
identities: Map<number, string> | null;
|
|
89
|
+
/** True iff `rootPid`'s CURRENT identity is POSITIVELY known to differ from
|
|
90
|
+
* `expectedIdentity` — the pid was already reused by an unrelated process
|
|
91
|
+
* before this snapshot was taken. Callers must skip ALL signaling when true. */
|
|
92
|
+
reused: boolean;
|
|
93
|
+
}
|
|
94
|
+
export declare function captureTeardownSnapshot(rootPid: number, expectedIdentity: string | null): TeardownSnapshot;
|
|
95
|
+
/** Best-effort signal against every pid in an ALREADY-CAPTURED tree snapshot
|
|
96
|
+
* (typically `[rootPid, ...descendantPids(rootPid)]`, taken ONCE by the
|
|
97
|
+
* caller before sending any signal — see the doc on `isProcessTreeAlive` for
|
|
98
|
+
* why re-deriving via `ps` after signaling starts is unsafe). Signals EACH
|
|
99
|
+
* pid's OWN process group (a detached SDK-spawned descendant is its own
|
|
100
|
+
* group leader — the pi bash tool's shell child, crouton-labs/crouter#98)
|
|
101
|
+
* PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is
|
|
102
|
+
* NOT its own group leader). Every signal is best-effort — `killProcessGroup`
|
|
103
|
+
* swallows ESRCH and the plain `kill` here does too — so firing this against
|
|
104
|
+
* an already-fully-dead snapshot is a harmless no-op.
|
|
105
|
+
*
|
|
106
|
+
* `identities`, when supplied (crouter#98 review finding 3), is a
|
|
107
|
+
* `capturePidIdentities` snapshot taken alongside the pid list at the SAME
|
|
108
|
+
* moment — `null` if that INITIAL capture's `ps` probe itself failed (see
|
|
109
|
+
* `capturePidIdentities`'s doc). Before signaling EITHER the group or the
|
|
110
|
+
* plain kill for a pid, this re-derives CURRENT identities in one fresh
|
|
111
|
+
* batched call and compares — but ONLY skips on POSITIVE proof of reuse; any
|
|
112
|
+
* failure or absence of evidence falls through to the normal signal path
|
|
113
|
+
* (fail-open, matching this module's pre-guard behavior — final review,
|
|
114
|
+
* crouter#98 signal-safety finding):
|
|
115
|
+
*
|
|
116
|
+
* - baseline identity present AND the current probe SUCCEEDED AND the
|
|
117
|
+
* current identity for this pid is present AND DIFFERENT — the OS has
|
|
118
|
+
* recycled this pid for an unrelated process since the snapshot — SKIP
|
|
119
|
+
* (the sole skip condition; this is the actual reused-pid hazard finding
|
|
120
|
+
* 3 targets).
|
|
121
|
+
* - baseline identity present but the pid is now simply GONE (current
|
|
122
|
+
* probe succeeded, no row for this pid) — it died naturally in the
|
|
123
|
+
* meantime, not reused — SIGNAL as normal (harmless no-op on the plain
|
|
124
|
+
* kill; load-bearing for the group kill, which can still reach a
|
|
125
|
+
* surviving process that shares this now-dead pid's process GROUP).
|
|
126
|
+
* - baseline had NO identity at all for this pid (the pid was ALREADY dead
|
|
127
|
+
* at snapshot time — e.g. a broker that crashed before `teardown()` ever
|
|
128
|
+
* ran) — SIGNAL as normal regardless of the current probe: this is
|
|
129
|
+
* exactly how `killProcessGroup` on an already-dead root still reaches a
|
|
130
|
+
* live process sharing its process group (the pgid persists after the
|
|
131
|
+
* group leader exits; see `host-teardown-process-group.test.ts`'s "dead
|
|
132
|
+
* broker" case).
|
|
133
|
+
* - the INITIAL capture's probe failed outright (`identities === null`) —
|
|
134
|
+
* we have NO baseline to compare against for ANY pid — SIGNAL as normal
|
|
135
|
+
* for the whole list; the CURRENT probe is not even attempted (nothing
|
|
136
|
+
* to compare it to).
|
|
137
|
+
* - the baseline succeeded but the CURRENT probe (taken here) fails — we
|
|
138
|
+
* have NO current information — SIGNAL as normal for the whole list; a
|
|
139
|
+
* failed probe must never be read as "gone" (would risk reaching a
|
|
140
|
+
* reused stranger, the residual risk this guard cannot close) NOR as
|
|
141
|
+
* "reused" (would wrongly disable the reap for every real live pid).
|
|
142
|
+
*
|
|
143
|
+
* Callers that omit `identities` entirely (pre-existing call sites) keep the
|
|
144
|
+
* prior unguarded behavior. */
|
|
145
|
+
export declare function killProcessTreePids(pids: readonly number[], signal?: NodeJS.Signals, identities?: ReadonlyMap<number, string> | null): void;
|
|
146
|
+
/** Is `rootPid`'s process TREE — itself or any CURRENT descendant — still
|
|
147
|
+
* alive, re-deriving the descendant set fresh via `ps` on every call? Safe
|
|
148
|
+
* ONLY before any signal has been sent to this tree. Once a signal kills the
|
|
149
|
+
* root (it's typically its own process-group leader — see `launch()` in
|
|
150
|
+
* host.ts), the kernel reparents any surviving child away from `rootPid`
|
|
151
|
+
* IMMEDIATELY (standard orphan handling, confirmed empirically: a killed
|
|
152
|
+
* parent's child shows `ppid=1` within the same tick, not lazily) —
|
|
153
|
+
* silently "losing" that child from a fresh `descendantPids` walk even
|
|
154
|
+
* though it is still very much alive. Use this ONLY to watch a tree you have
|
|
155
|
+
* not touched yet (e.g. waiting out a graceful-shutdown grace window); once
|
|
156
|
+
* you start signaling, capture the pid list ONCE and check it with
|
|
157
|
+
* `isAnyPidAlive` instead — see `killProcessTreePids`. */
|
|
158
|
+
export declare function isProcessTreeAlive(rootPid: number): boolean;
|
|
159
|
+
/** Is any pid in an ALREADY-CAPTURED pid list (see `killProcessTreePids`)
|
|
160
|
+
* still alive? Pure signal-0 check against the fixed list — NEVER re-walks
|
|
161
|
+
* `ps` — the correct liveness check once you've started signaling a tree
|
|
162
|
+
* (see `isProcessTreeAlive`'s doc for why re-deriving after that point would
|
|
163
|
+
* silently drop a still-alive orphan). */
|
|
164
|
+
export declare function isAnyPidAlive(pids: readonly number[]): boolean;
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
// so it is exempt from the "only canvas.ts touches the db" rule. Collapses the
|
|
8
8
|
// four near-identical copies that used to live in canvas.ts, revive.ts,
|
|
9
9
|
// placement.ts, and crtrd.ts into one (Phase 3 review reuse MINOR-1).
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
10
12
|
/** True if a process with `pid` is currently alive (signal-0 probe). `kill(pid,
|
|
11
13
|
* 0)` throws ESRCH when the process is gone; EPERM means it exists but isn't
|
|
12
14
|
* ours — still alive. A null/undefined pid (legacy / never-booted) reads dead. */
|
|
@@ -25,8 +27,12 @@ export function isPidAlive(pid) {
|
|
|
25
27
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
|
26
28
|
* an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
|
|
27
29
|
* timeout + stale-lease recovery kills). Lives beside isPidAlive: canvas/ is
|
|
28
|
-
* the lowest shared layer every process-liveness/-teardown primitive sits at.
|
|
30
|
+
* the lowest shared layer every process-liveness/-teardown primitive sits at.
|
|
31
|
+
* Guards `pid` before signaling (review nit, crouter#98): never hit pid 0
|
|
32
|
+
* (which means "my own group") or a negative/non-integer value. */
|
|
29
33
|
export function killProcessGroup(pid, signal = 'SIGTERM') {
|
|
34
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
35
|
+
return;
|
|
30
36
|
try {
|
|
31
37
|
process.kill(-pid, signal);
|
|
32
38
|
}
|
|
@@ -34,3 +40,302 @@ export function killProcessGroup(pid, signal = 'SIGTERM') {
|
|
|
34
40
|
/* already gone */
|
|
35
41
|
}
|
|
36
42
|
}
|
|
43
|
+
/** Linux-only higher-resolution discriminator layered onto `lstart` (crouter#98
|
|
44
|
+
* review finding, minor): `ps lstart` is only second-granular, so a PID reused
|
|
45
|
+
* within the same wall-clock second compares equal and slips past the reuse
|
|
46
|
+
* guard. `/proc/<pid>/stat`'s `starttime` field is the kernel's own jiffies-
|
|
47
|
+
* since-boot counter for that process — effectively unique per pid even for
|
|
48
|
+
* same-second reuse. `comm` (field 2) is parenthesized and may itself contain
|
|
49
|
+
* spaces/parens, so this splits on the LAST `)` rather than counting fields
|
|
50
|
+
* from the front. Returns `null` (never throws) on any read/parse failure —
|
|
51
|
+
* no `/proc` (macOS, containers without procfs), a raced-away pid, or a
|
|
52
|
+
* malformed line — so this is purely additive precision, never a hard
|
|
53
|
+
* dependency: `composeIdentity` falls back to plain `lstart` in every such
|
|
54
|
+
* case, exactly matching this module's pre-existing cross-platform coarse
|
|
55
|
+
* behavior. */
|
|
56
|
+
function procStartTicks(pid) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
59
|
+
const closeParen = raw.lastIndexOf(')');
|
|
60
|
+
if (closeParen === -1)
|
|
61
|
+
return null;
|
|
62
|
+
// Fields after `(comm)`, 0-indexed from `state` (proc(5) field 3): state(0)
|
|
63
|
+
// ppid(1) pgrp(2) session(3) tty_nr(4) tpgid(5) flags(6) minflt(7)
|
|
64
|
+
// cminflt(8) majflt(9) cmajflt(10) utime(11) stime(12) cutime(13)
|
|
65
|
+
// cstime(14) priority(15) nice(16) num_threads(17) itrealvalue(18)
|
|
66
|
+
// starttime(19) — proc(5) field 22.
|
|
67
|
+
const rest = raw.slice(closeParen + 2).trim().split(/\s+/);
|
|
68
|
+
const starttime = rest[19];
|
|
69
|
+
return starttime !== undefined && /^\d+$/.test(starttime) ? starttime : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Compose the stable process-identity fingerprint used everywhere in this
|
|
76
|
+
* module: `ps lstart` (portable, BSD + Linux) plus the Linux-only jiffies
|
|
77
|
+
* discriminator when available (best-effort — see `procStartTicks`). ALWAYS
|
|
78
|
+
* used to build an identity string, never `lstart` alone directly, so every
|
|
79
|
+
* identity captured anywhere (launch-time `recordPid`, the teardown
|
|
80
|
+
* snapshot, and the escalation-window re-check) is comparable apples-to-
|
|
81
|
+
* apples. */
|
|
82
|
+
function composeIdentity(pid, lstart) {
|
|
83
|
+
const ticks = procStartTicks(pid);
|
|
84
|
+
return ticks !== null ? `${lstart}#${ticks}` : lstart;
|
|
85
|
+
}
|
|
86
|
+
/** One combined `ps` probe over the WHOLE process table — `pid`, `ppid`, AND
|
|
87
|
+
* `lstart` per row — parsed into BOTH a ppid → children-pid[] map (for
|
|
88
|
+
* descendant-tree walks) and a pid → identity map (`composeIdentity`), from a
|
|
89
|
+
* SINGLE `spawnSync` call. Shared by `descendantPids` and
|
|
90
|
+
* `captureTeardownSnapshot` so tree membership and identity are always read
|
|
91
|
+
* off the SAME table snapshot — closing the small race (crouter#98 final
|
|
92
|
+
* review) where two separate `ps` calls could see the process table change
|
|
93
|
+
* between them. Returns `null` (never throws) on any `ps` failure. */
|
|
94
|
+
function capturePsTable() {
|
|
95
|
+
let stdout;
|
|
96
|
+
try {
|
|
97
|
+
const r = spawnSync('ps', ['-Ao', 'pid=,ppid=,lstart='], { encoding: 'utf8', timeout: 2000 });
|
|
98
|
+
if (r.status !== 0 || typeof r.stdout !== 'string')
|
|
99
|
+
return null;
|
|
100
|
+
stdout = r.stdout;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const childrenOf = new Map();
|
|
106
|
+
const identities = new Map();
|
|
107
|
+
for (const line of stdout.split('\n')) {
|
|
108
|
+
const trimmed = line.trim();
|
|
109
|
+
if (trimmed === '')
|
|
110
|
+
continue;
|
|
111
|
+
// pid and ppid are the two leading whitespace-delimited numeric tokens;
|
|
112
|
+
// `lstart` (a human-readable timestamp that itself CONTAINS spaces, e.g.
|
|
113
|
+
// "Wed Jul 3 10:23:45 2026") is everything after them, kept verbatim.
|
|
114
|
+
const m = /^(\d+)\s+(\d+)\s+(.+)$/.exec(trimmed);
|
|
115
|
+
if (m === null)
|
|
116
|
+
continue;
|
|
117
|
+
const pid = Number(m[1]);
|
|
118
|
+
const ppid = Number(m[2]);
|
|
119
|
+
const lstart = m[3].trim();
|
|
120
|
+
if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid <= 0 || lstart === '')
|
|
121
|
+
continue;
|
|
122
|
+
identities.set(pid, composeIdentity(pid, lstart));
|
|
123
|
+
const siblings = childrenOf.get(ppid);
|
|
124
|
+
if (siblings === undefined)
|
|
125
|
+
childrenOf.set(ppid, [pid]);
|
|
126
|
+
else
|
|
127
|
+
siblings.push(pid);
|
|
128
|
+
}
|
|
129
|
+
return { childrenOf, identities };
|
|
130
|
+
}
|
|
131
|
+
/** BFS over an already-parsed ppid → children-pid[] map, starting from
|
|
132
|
+
* `rootPid`'s children. Pure (no `ps` spawn) — split out of `descendantPids`
|
|
133
|
+
* so `captureTeardownSnapshot` can walk the SAME `capturePsTable()` result it
|
|
134
|
+
* already paid for, rather than re-spawning `ps`. */
|
|
135
|
+
function descendantPidsFrom(rootPid, childrenOf) {
|
|
136
|
+
const out = [];
|
|
137
|
+
const seen = new Set([rootPid]);
|
|
138
|
+
const queue = [...(childrenOf.get(rootPid) ?? [])];
|
|
139
|
+
while (queue.length > 0) {
|
|
140
|
+
const pid = queue.shift();
|
|
141
|
+
if (seen.has(pid))
|
|
142
|
+
continue; // guard against a malformed/cyclic ps snapshot
|
|
143
|
+
seen.add(pid);
|
|
144
|
+
out.push(pid);
|
|
145
|
+
const kids = childrenOf.get(pid);
|
|
146
|
+
if (kids !== undefined)
|
|
147
|
+
queue.push(...kids);
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
/** Every transitive descendant of `rootPid`, discovered by walking `ps`'s
|
|
152
|
+
* pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for
|
|
153
|
+
* crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with
|
|
154
|
+
* `detached: true` (verified in `@earendil-works/pi-agent-core`'s
|
|
155
|
+
* `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a
|
|
156
|
+
* brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it.
|
|
157
|
+
* `setsid()` changes only SID/PGID, NEVER ppid, so the child (and its own
|
|
158
|
+
* descendants) is still discoverable by walking parentage from `rootPid`, as
|
|
159
|
+
* long as `rootPid`'s OS process is still alive when we walk (a fully-dead
|
|
160
|
+
* parent is reparented away by the kernel immediately on exit, severing this
|
|
161
|
+
* link — the wedged-but-alive broker this fix targets never hits that). Best-
|
|
162
|
+
* effort: returns `[]` (never throws) on any `ps` failure, exactly like
|
|
163
|
+
* `killProcessGroup`'s ESRCH-swallowing. */
|
|
164
|
+
export function descendantPids(rootPid) {
|
|
165
|
+
if (!Number.isInteger(rootPid) || rootPid <= 0)
|
|
166
|
+
return [];
|
|
167
|
+
const table = capturePsTable();
|
|
168
|
+
if (table === null)
|
|
169
|
+
return [];
|
|
170
|
+
return descendantPidsFrom(rootPid, table.childrenOf);
|
|
171
|
+
}
|
|
172
|
+
/** Capture a portable process-identity fingerprint — `ps`'s `lstart` column,
|
|
173
|
+
* the process's full wall-clock start time — for each of `pids`, in ONE
|
|
174
|
+
* batched `ps -p <list>` call. `lstart` is a standard format keyword on BOTH
|
|
175
|
+
* BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or
|
|
176
|
+
* other Linux-only machinery. Used to guard against PID REUSE during the
|
|
177
|
+
* multi-second teardown escalation ladder (crouter#98 review finding 3): a
|
|
178
|
+
* captured pid can exit and the OS can recycle it for an unrelated process
|
|
179
|
+
* before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)`
|
|
180
|
+
* cannot tell the difference.
|
|
181
|
+
*
|
|
182
|
+
* Returns `null` — NOT an empty map — when the `ps` PROBE ITSELF failed (spawn
|
|
183
|
+
* error, non-zero exit, non-string stdout): a probe failure means we have NO
|
|
184
|
+
* information, which is a fundamentally different outcome from a probe that
|
|
185
|
+
* SUCCEEDED and simply found no row for a given pid (that pid is genuinely
|
|
186
|
+
* gone). Collapsing these two into the same empty map was the final-review
|
|
187
|
+
* bug (crouter#98 signal-safety finding): `killProcessTreePids` cannot tell
|
|
188
|
+
* "pid confirmed gone" from "we don't know" without this distinction, and
|
|
189
|
+
* misreading a failed probe as positive proof of absence/reuse either signals
|
|
190
|
+
* a reused stranger or — worse — silently skips reaping a real live orphan.
|
|
191
|
+
* A successful call may still return a map with no entry for some/all of
|
|
192
|
+
* `pids` (those pids have no current row — genuinely gone); that is the
|
|
193
|
+
* valid empty-or-partial case and is NOT a failure. */
|
|
194
|
+
export function capturePidIdentities(pids) {
|
|
195
|
+
const valid = pids.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
196
|
+
const out = new Map();
|
|
197
|
+
if (valid.length === 0)
|
|
198
|
+
return out;
|
|
199
|
+
try {
|
|
200
|
+
const r = spawnSync('ps', ['-o', 'pid=,lstart=', '-p', valid.join(',')], { encoding: 'utf8', timeout: 2000 });
|
|
201
|
+
if (r.status !== 0 || typeof r.stdout !== 'string')
|
|
202
|
+
return null;
|
|
203
|
+
for (const line of r.stdout.split('\n')) {
|
|
204
|
+
const trimmed = line.trim();
|
|
205
|
+
if (trimmed === '')
|
|
206
|
+
continue;
|
|
207
|
+
// Only the FIRST token (pid) is split on whitespace — `lstart` is a
|
|
208
|
+
// human-readable timestamp that itself CONTAINS spaces (e.g. "Wed Jul
|
|
209
|
+
// 3 10:23:45 2026"), so everything after the first space is kept
|
|
210
|
+
// verbatim as the identity string. Exact string equality later is
|
|
211
|
+
// sufficient; nothing here needs to parse it as a real date.
|
|
212
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
213
|
+
if (spaceIdx === -1)
|
|
214
|
+
continue;
|
|
215
|
+
const pid = Number(trimmed.slice(0, spaceIdx));
|
|
216
|
+
const startedAt = trimmed.slice(spaceIdx + 1).trim();
|
|
217
|
+
if (!Number.isInteger(pid) || pid <= 0 || startedAt === '')
|
|
218
|
+
continue;
|
|
219
|
+
out.set(pid, composeIdentity(pid, startedAt));
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export function captureTeardownSnapshot(rootPid, expectedIdentity) {
|
|
228
|
+
if (!Number.isInteger(rootPid) || rootPid <= 0)
|
|
229
|
+
return { tree: [], identities: null, reused: false };
|
|
230
|
+
const table = capturePsTable();
|
|
231
|
+
if (table === null)
|
|
232
|
+
return { tree: [rootPid], identities: null, reused: false };
|
|
233
|
+
const currentRootIdentity = table.identities.get(rootPid);
|
|
234
|
+
const reused = expectedIdentity != null && currentRootIdentity !== undefined && currentRootIdentity !== expectedIdentity;
|
|
235
|
+
if (reused)
|
|
236
|
+
return { tree: [], identities: null, reused: true };
|
|
237
|
+
const tree = [rootPid, ...descendantPidsFrom(rootPid, table.childrenOf)];
|
|
238
|
+
const identities = new Map();
|
|
239
|
+
for (const pid of tree) {
|
|
240
|
+
const id = table.identities.get(pid);
|
|
241
|
+
if (id !== undefined)
|
|
242
|
+
identities.set(pid, id);
|
|
243
|
+
}
|
|
244
|
+
return { tree, identities, reused: false };
|
|
245
|
+
}
|
|
246
|
+
/** Best-effort signal against every pid in an ALREADY-CAPTURED tree snapshot
|
|
247
|
+
* (typically `[rootPid, ...descendantPids(rootPid)]`, taken ONCE by the
|
|
248
|
+
* caller before sending any signal — see the doc on `isProcessTreeAlive` for
|
|
249
|
+
* why re-deriving via `ps` after signaling starts is unsafe). Signals EACH
|
|
250
|
+
* pid's OWN process group (a detached SDK-spawned descendant is its own
|
|
251
|
+
* group leader — the pi bash tool's shell child, crouton-labs/crouter#98)
|
|
252
|
+
* PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is
|
|
253
|
+
* NOT its own group leader). Every signal is best-effort — `killProcessGroup`
|
|
254
|
+
* swallows ESRCH and the plain `kill` here does too — so firing this against
|
|
255
|
+
* an already-fully-dead snapshot is a harmless no-op.
|
|
256
|
+
*
|
|
257
|
+
* `identities`, when supplied (crouter#98 review finding 3), is a
|
|
258
|
+
* `capturePidIdentities` snapshot taken alongside the pid list at the SAME
|
|
259
|
+
* moment — `null` if that INITIAL capture's `ps` probe itself failed (see
|
|
260
|
+
* `capturePidIdentities`'s doc). Before signaling EITHER the group or the
|
|
261
|
+
* plain kill for a pid, this re-derives CURRENT identities in one fresh
|
|
262
|
+
* batched call and compares — but ONLY skips on POSITIVE proof of reuse; any
|
|
263
|
+
* failure or absence of evidence falls through to the normal signal path
|
|
264
|
+
* (fail-open, matching this module's pre-guard behavior — final review,
|
|
265
|
+
* crouter#98 signal-safety finding):
|
|
266
|
+
*
|
|
267
|
+
* - baseline identity present AND the current probe SUCCEEDED AND the
|
|
268
|
+
* current identity for this pid is present AND DIFFERENT — the OS has
|
|
269
|
+
* recycled this pid for an unrelated process since the snapshot — SKIP
|
|
270
|
+
* (the sole skip condition; this is the actual reused-pid hazard finding
|
|
271
|
+
* 3 targets).
|
|
272
|
+
* - baseline identity present but the pid is now simply GONE (current
|
|
273
|
+
* probe succeeded, no row for this pid) — it died naturally in the
|
|
274
|
+
* meantime, not reused — SIGNAL as normal (harmless no-op on the plain
|
|
275
|
+
* kill; load-bearing for the group kill, which can still reach a
|
|
276
|
+
* surviving process that shares this now-dead pid's process GROUP).
|
|
277
|
+
* - baseline had NO identity at all for this pid (the pid was ALREADY dead
|
|
278
|
+
* at snapshot time — e.g. a broker that crashed before `teardown()` ever
|
|
279
|
+
* ran) — SIGNAL as normal regardless of the current probe: this is
|
|
280
|
+
* exactly how `killProcessGroup` on an already-dead root still reaches a
|
|
281
|
+
* live process sharing its process group (the pgid persists after the
|
|
282
|
+
* group leader exits; see `host-teardown-process-group.test.ts`'s "dead
|
|
283
|
+
* broker" case).
|
|
284
|
+
* - the INITIAL capture's probe failed outright (`identities === null`) —
|
|
285
|
+
* we have NO baseline to compare against for ANY pid — SIGNAL as normal
|
|
286
|
+
* for the whole list; the CURRENT probe is not even attempted (nothing
|
|
287
|
+
* to compare it to).
|
|
288
|
+
* - the baseline succeeded but the CURRENT probe (taken here) fails — we
|
|
289
|
+
* have NO current information — SIGNAL as normal for the whole list; a
|
|
290
|
+
* failed probe must never be read as "gone" (would risk reaching a
|
|
291
|
+
* reused stranger, the residual risk this guard cannot close) NOR as
|
|
292
|
+
* "reused" (would wrongly disable the reap for every real live pid).
|
|
293
|
+
*
|
|
294
|
+
* Callers that omit `identities` entirely (pre-existing call sites) keep the
|
|
295
|
+
* prior unguarded behavior. */
|
|
296
|
+
export function killProcessTreePids(pids, signal = 'SIGTERM', identities) {
|
|
297
|
+
const current = identities != null ? capturePidIdentities(pids) : undefined;
|
|
298
|
+
for (const pid of pids) {
|
|
299
|
+
if (identities != null && current != null) {
|
|
300
|
+
const expected = identities.get(pid);
|
|
301
|
+
const actual = current.get(pid);
|
|
302
|
+
const reused = expected !== undefined && actual !== undefined && actual !== expected;
|
|
303
|
+
if (reused)
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
killProcessGroup(pid, signal);
|
|
307
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
308
|
+
continue;
|
|
309
|
+
try {
|
|
310
|
+
process.kill(pid, signal);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* already gone */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Is `rootPid`'s process TREE — itself or any CURRENT descendant — still
|
|
318
|
+
* alive, re-deriving the descendant set fresh via `ps` on every call? Safe
|
|
319
|
+
* ONLY before any signal has been sent to this tree. Once a signal kills the
|
|
320
|
+
* root (it's typically its own process-group leader — see `launch()` in
|
|
321
|
+
* host.ts), the kernel reparents any surviving child away from `rootPid`
|
|
322
|
+
* IMMEDIATELY (standard orphan handling, confirmed empirically: a killed
|
|
323
|
+
* parent's child shows `ppid=1` within the same tick, not lazily) —
|
|
324
|
+
* silently "losing" that child from a fresh `descendantPids` walk even
|
|
325
|
+
* though it is still very much alive. Use this ONLY to watch a tree you have
|
|
326
|
+
* not touched yet (e.g. waiting out a graceful-shutdown grace window); once
|
|
327
|
+
* you start signaling, capture the pid list ONCE and check it with
|
|
328
|
+
* `isAnyPidAlive` instead — see `killProcessTreePids`. */
|
|
329
|
+
export function isProcessTreeAlive(rootPid) {
|
|
330
|
+
if (isPidAlive(rootPid))
|
|
331
|
+
return true;
|
|
332
|
+
return descendantPids(rootPid).some((pid) => isPidAlive(pid));
|
|
333
|
+
}
|
|
334
|
+
/** Is any pid in an ALREADY-CAPTURED pid list (see `killProcessTreePids`)
|
|
335
|
+
* still alive? Pure signal-0 check against the fixed list — NEVER re-walks
|
|
336
|
+
* `ps` — the correct liveness check once you've started signaling a tree
|
|
337
|
+
* (see `isProcessTreeAlive`'s doc for why re-deriving after that point would
|
|
338
|
+
* silently drop a still-alive orphan). */
|
|
339
|
+
export function isAnyPidAlive(pids) {
|
|
340
|
+
return pids.some((pid) => isPidAlive(pid));
|
|
341
|
+
}
|
|
@@ -23,6 +23,13 @@ export declare function resolveNodeVisual(status: NodeStatus, opts: {
|
|
|
23
23
|
kind: FaultKind;
|
|
24
24
|
} | null;
|
|
25
25
|
}): NodeVisual;
|
|
26
|
+
/** True for exactly the fault dispositions `faultDispositionText` renders as
|
|
27
|
+
* "needs you" — nothing (sdk/daemon/client) is auto-driving recovery, so the
|
|
28
|
+
* dashboard flags the node for the human. Exported so a recovery verb (e.g.
|
|
29
|
+
* `canvas revive --now`, src/commands/revive.ts) can gate on the SAME
|
|
30
|
+
* predicate the dashboard advertises instead of drifting out of sync with it
|
|
31
|
+
* (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
|
|
32
|
+
export declare function faultNeedsYou(fault: Pick<Fault, 'retry'>): boolean;
|
|
26
33
|
/** Human-friendly active-fault disposition text. `auto` faults show the current
|
|
27
34
|
* retry attempt plus the next scheduled retry; client-owned redials say
|
|
28
35
|
* `redialing`; everything else says `needs you`. */
|
|
@@ -70,12 +70,21 @@ function retryCountdown(iso, now) {
|
|
|
70
70
|
return `${Math.max(1, Math.round(remaining / 60_000))}m`;
|
|
71
71
|
return `${Math.max(1, Math.ceil(remaining / 1000))}s`;
|
|
72
72
|
}
|
|
73
|
+
/** True for exactly the fault dispositions `faultDispositionText` renders as
|
|
74
|
+
* "needs you" — nothing (sdk/daemon/client) is auto-driving recovery, so the
|
|
75
|
+
* dashboard flags the node for the human. Exported so a recovery verb (e.g.
|
|
76
|
+
* `canvas revive --now`, src/commands/revive.ts) can gate on the SAME
|
|
77
|
+
* predicate the dashboard advertises instead of drifting out of sync with it
|
|
78
|
+
* (issue #115 — `--now` used to reject exactly the nodes shown as "needs you"). */
|
|
79
|
+
export function faultNeedsYou(fault) {
|
|
80
|
+
return fault.retry.disposition !== 'auto';
|
|
81
|
+
}
|
|
73
82
|
/** Human-friendly active-fault disposition text. `auto` faults show the current
|
|
74
83
|
* retry attempt plus the next scheduled retry; client-owned redials say
|
|
75
84
|
* `redialing`; everything else says `needs you`. */
|
|
76
85
|
export function faultDispositionText(fault, now = Date.now()) {
|
|
77
86
|
const retry = fault.retry;
|
|
78
|
-
if (
|
|
87
|
+
if (!faultNeedsYou(fault)) {
|
|
79
88
|
if (retry.by === 'client')
|
|
80
89
|
return 'redialing';
|
|
81
90
|
const progress = typeof retry.attempt === 'number' && typeof retry.max === 'number'
|
|
@@ -35,6 +35,16 @@ export interface LaunchSpec {
|
|
|
35
35
|
/** Extra env injected into the pi process. */
|
|
36
36
|
env: Record<string, string>;
|
|
37
37
|
}
|
|
38
|
+
export interface ManagedWorktree {
|
|
39
|
+
state: 'open' | 'closed';
|
|
40
|
+
path: string;
|
|
41
|
+
branch: string;
|
|
42
|
+
repo_root: string;
|
|
43
|
+
base_ref: string;
|
|
44
|
+
base_sha: string;
|
|
45
|
+
created: string;
|
|
46
|
+
closed?: string;
|
|
47
|
+
}
|
|
38
48
|
/** A node's DURABLE IDENTITY — the subset that `meta.json` persists on disk.
|
|
39
49
|
* Written rarely (birth, polymorph, session-id capture, naming); never touched
|
|
40
50
|
* by a status flip, an intent change, a focus swap, or a pid stamp. The db row
|
|
@@ -106,6 +116,15 @@ export interface NodeIdentity {
|
|
|
106
116
|
* PATH is opened directly — immune to any cwd discrepancy. Null for older
|
|
107
117
|
* nodes booted before this field existed → revive falls back to the bare id. */
|
|
108
118
|
pi_session_file?: string | null;
|
|
119
|
+
/** True while a cycling (refresh-yield, `--crtr-new-cycle`) launch attempt is
|
|
120
|
+
* IN FLIGHT and has not yet reached `session_start` — set by reviveNode right
|
|
121
|
+
* before it launches a cycling engine, cleared by canvas-stophook's
|
|
122
|
+
* session_start handler the instant that boot is confirmed. Lets a pre-
|
|
123
|
+
* session_start crash-retry (crtrd.ts) detect "the dead attempt I'm retrying
|
|
124
|
+
* was itself a cycle" and retry AS a cycle (resume:false) instead of a strict
|
|
125
|
+
* resume — otherwise the retry stops after the first crash-loop attempt and
|
|
126
|
+
* strands the node in an empty marker branch with no kickoff. */
|
|
127
|
+
cycle_pending?: boolean | null;
|
|
109
128
|
/** A caller-pinned model TIER (ultra/strong/medium/light) that overrides the
|
|
110
129
|
* persona's declared default. Durable so it survives a polymorph: every
|
|
111
130
|
* spec-rebuild path (promote/demote/lifecycle/reset) re-passes it to
|
|
@@ -114,6 +133,8 @@ export interface NodeIdentity {
|
|
|
114
133
|
model_override?: string | null;
|
|
115
134
|
/** Full pi launch recipe; rewritten on every polymorph. */
|
|
116
135
|
launch?: LaunchSpec;
|
|
136
|
+
/** Managed git worktree owned by this node (if any). */
|
|
137
|
+
managed_worktree?: ManagedWorktree | null;
|
|
117
138
|
}
|
|
118
139
|
/** A node's LIVE RUNTIME state — authoritative in the WAL'd `nodes` row, each
|
|
119
140
|
* field mutated by exactly one atomic single-statement `UPDATE` (setStatus /
|
|
@@ -134,6 +155,17 @@ export interface NodeRuntime {
|
|
|
134
155
|
* reviveNode until the fresh broker re-records it, so the relaunch gap reads as
|
|
135
156
|
* a dead pid (guarded by the daemon's revive grace, not a double-spawn). */
|
|
136
157
|
pi_pid?: number | null;
|
|
158
|
+
/** Stable process-identity fingerprint (`pid.ts`'s `composeIdentity`) captured
|
|
159
|
+
* at the SAME moment `pi_pid` is recorded (`recordPid`) — the LAUNCH-time
|
|
160
|
+
* baseline `headlessBrokerHost.teardown()` compares `pi_pid`'s CURRENT
|
|
161
|
+
* identity against before signaling anything (crouter#98 review finding 1):
|
|
162
|
+
* a mismatch proves the OS has already recycled `pi_pid` for an unrelated
|
|
163
|
+
* process since this node launched it, and teardown must refuse to signal
|
|
164
|
+
* that stranger. `null` for a node booted before this field existed, or if
|
|
165
|
+
* the launch-time identity capture itself failed (fail-open — no baseline
|
|
166
|
+
* means no guard, not a false mismatch). Cleared to null alongside `pi_pid`
|
|
167
|
+
* by `clearPid` and the boot-liveness sweep (a new boot invalidates both). */
|
|
168
|
+
pi_pid_identity?: string | null;
|
|
137
169
|
/** Presence: the tmux session (its root's home) and window this node renders
|
|
138
170
|
* in while active. Cleared when the node goes done/dead and its window closes.
|
|
139
171
|
* The row IS the presence registry (one atomic setPresence per move).
|
|
@@ -176,6 +208,8 @@ export interface NodeRow {
|
|
|
176
208
|
/** Authoritative runtime columns (see NodeRuntime). */
|
|
177
209
|
intent: ExitIntent;
|
|
178
210
|
pi_pid: number | null;
|
|
211
|
+
/** See `NodeRuntime.pi_pid_identity`. */
|
|
212
|
+
pi_pid_identity: string | null;
|
|
179
213
|
window: string | null;
|
|
180
214
|
tmux_session: string | null;
|
|
181
215
|
/** The durable LOCATION handle (the tmux `%pane_id`); see NodeRuntime.pane. */
|
|
@@ -6,11 +6,15 @@ function text(value) {
|
|
|
6
6
|
}
|
|
7
7
|
function classifyProviderText(message) {
|
|
8
8
|
const m = message ?? '';
|
|
9
|
-
// ORDER MATTERS: rate-limit → overloaded → connection → other.
|
|
9
|
+
// ORDER MATTERS: rate-limit → overloaded → auth → connection → other.
|
|
10
|
+
// auth precedes connection so "refresh token not found" (a dead OAuth grant)
|
|
11
|
+
// reads as fatal auth, not a transient not-found/connection blip.
|
|
10
12
|
if (/rate.?limit|\b429\b|too many requests|quota/i.test(m))
|
|
11
13
|
return 'rate-limit';
|
|
12
14
|
if (/overloaded|\b529\b|\b503\b|capacity|server.{0,3}busy|temporarily unavailable/i.test(m))
|
|
13
15
|
return 'overloaded';
|
|
16
|
+
if (/invalid_grant|refresh token|unauthori[sz]ed|invalid.?api.?key|authentication failed|\b401\b|\b403\b/i.test(m))
|
|
17
|
+
return 'auth';
|
|
14
18
|
if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout/i.test(m))
|
|
15
19
|
return 'connection';
|
|
16
20
|
return 'other';
|
|
@@ -446,6 +446,10 @@ export function buildContextBearings(nodeId) {
|
|
|
446
446
|
const profileSection = profileNote(nodeId);
|
|
447
447
|
if (profileSection !== '')
|
|
448
448
|
bearings.push('', profileSection);
|
|
449
|
+
const wt = node?.managed_worktree;
|
|
450
|
+
if (wt?.state === 'open') {
|
|
451
|
+
bearings.push('', '## Your managed worktree', `Branch: \`${wt.branch}\`\nWorktree: \`${wt.path}\`\nBase: \`${wt.base_ref}\` @ \`${wt.base_sha}\`\nCommit work here. Land it with \`crtr worktree close\` before finishing; \`crtr push final\` is blocked until this worktree is closed.`);
|
|
452
|
+
}
|
|
449
453
|
// Orchestrator-only: the across-cycles framing (a terminal has no future cycle).
|
|
450
454
|
if (node?.mode === 'orchestrator')
|
|
451
455
|
bearings.push('', orchestratorContextNote(nodeId));
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* to the installed node, shared across every canvas home. */
|
|
4
4
|
export declare function brandedHostDir(): string;
|
|
5
5
|
export declare function brandedHostBin(): string;
|
|
6
|
+
/** macOS keys FDA grants on the Mach-O cdhash. A real developer identity
|
|
7
|
+
* keeps that hash stable across branded-host rebuilds; ad-hoc signing is the
|
|
8
|
+
* portable default for machines without a local signing certificate. */
|
|
9
|
+
export declare function codesignIdentitiesForBrandedHost(opts?: {
|
|
10
|
+
configured?: string | null;
|
|
11
|
+
discovered?: string | null;
|
|
12
|
+
}): string[];
|
|
6
13
|
export declare function signBrandedHostArtifacts(binPath: string, libnodePath: string | null, sign?: (path: string) => void): void;
|
|
7
14
|
export declare function brandedHostWorks(binPath: string): boolean;
|
|
8
15
|
/** Ensure the branded host binary exists and matches the live node; return its
|