@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
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/host-teardown-process-group.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Regression cover for crouton-labs/crouter#98: a canceled/wedged child node
|
|
4
|
+
// ORPHANS its subprocess tree (bash → test runner → spawned app) because
|
|
5
|
+
// teardown sent a single SIGTERM to the broker pid ONLY — no group signal, no
|
|
6
|
+
// SIGKILL escalation, and (the deeper gap) no awareness that the pi SDK's
|
|
7
|
+
// bash tool spawns its shell child `detached: true`, which calls `setsid()`
|
|
8
|
+
// and lands that child in a brand-new process GROUP of its own, distinct from
|
|
9
|
+
// the broker's. A plain `kill(brokerPid, sig)` — and even a GROUP kill of
|
|
10
|
+
// `-brokerPid` — never reaches a descendant shaped that way; only walking the
|
|
11
|
+
// process TREE by ppid (which `setsid()` never changes) and signaling each
|
|
12
|
+
// discovered descendant's OWN group actually reaps it.
|
|
13
|
+
//
|
|
14
|
+
// These tests drive `headlessBrokerHost.teardown()` directly (no real pi
|
|
15
|
+
// engine, no tmux) against REAL fabricated process trees standing in for
|
|
16
|
+
// "broker (session leader) → descendant", asserting against REAL pids read
|
|
17
|
+
// back with `isPidAlive` — never a mock.
|
|
18
|
+
//
|
|
19
|
+
// (1) BUG LOCKED — four locks:
|
|
20
|
+
// • "dead broker orphans a same-group descendant" — the fake broker is
|
|
21
|
+
// SIGKILLed out from under us BEFORE teardown runs (so `getNode().pi_pid`
|
|
22
|
+
// is a pid that's already gone), leaving its non-detached child alive in
|
|
23
|
+
// the SAME process group. A plain `kill(deadPid, 'SIGTERM')` throws
|
|
24
|
+
// ESRCH and touches nothing else; the broker's own group is what must
|
|
25
|
+
// be reaped (its pgid is untouched by the kernel reparenting the child's
|
|
26
|
+
// ppid to init, which happens immediately on the broker's exit).
|
|
27
|
+
// • "wedged broker orphans a DETACHED descendant" (the exact GH repro
|
|
28
|
+
// shape — the current test's core addition): the broker is alive but
|
|
29
|
+
// unreachable (nothing listening on view.sock), and its descendant
|
|
30
|
+
// detached into its OWN process group exactly like the pi bash tool
|
|
31
|
+
// does. We assert structurally that the descendant's pgid differs from
|
|
32
|
+
// the broker's pid — proof a bare `kill(-brokerPid)` cannot reach it —
|
|
33
|
+
// then prove the real `teardown()` (ppid-tree-walk) reaps BOTH the
|
|
34
|
+
// broker and the detached descendant.
|
|
35
|
+
// • "detached descendant ignores SIGTERM" (a stubborn/wedged leaf): must
|
|
36
|
+
// survive the initial tree-wide SIGTERM and only die once the SIGKILL
|
|
37
|
+
// escalation fires after the grace window — proving the ladder actually
|
|
38
|
+
// reaches SIGKILL against a descendant's OWN group, not just the
|
|
39
|
+
// broker's.
|
|
40
|
+
// • "CONNECTED broker exits cleanly after the shutdown frame but leaves a
|
|
41
|
+
// DETACHED descendant alive" (review finding 1, the CONNECTED/shutdown
|
|
42
|
+
// path — the fake broker here is a real unix-socket server on the
|
|
43
|
+
// node's view.sock that ACCEPTS the connection + shutdown frame, then
|
|
44
|
+
// exits WITHOUT reaping its own detached descendant): proves teardown's
|
|
45
|
+
// post-shutdown-frame liveness/escalation check reuses the ONE tree
|
|
46
|
+
// snapshot taken before the broker could exit, rather than re-walking
|
|
47
|
+
// `ps` by ppid after the broker is gone — a re-walk finds nothing the
|
|
48
|
+
// instant the kernel reparents the surviving descendant away from the
|
|
49
|
+
// now-dead broker's pid, silently skipping escalation forever.
|
|
50
|
+
//
|
|
51
|
+
// (2) HOW THIS FAILS IF THE BUG REGRESSES — reverting to a single
|
|
52
|
+
// `process.kill(pid, 'SIGTERM')` (or even `killProcessGroup(pid, sig)`
|
|
53
|
+
// alone, with no ppid-tree walk) of the broker pid leaves the detached-
|
|
54
|
+
// descendant assertions in this file red: those pids stay alive
|
|
55
|
+
// indefinitely (the `waitFor` polls time out), and the SIGTERM-ignoring
|
|
56
|
+
// detached leaf never dies at all. Reverting ONLY the connected-path fix
|
|
57
|
+
// (having `teardown()` re-derive the tree via a fresh `isProcessTreeAlive`/
|
|
58
|
+
// `descendantPids` walk after the shutdown frame, instead of reusing the
|
|
59
|
+
// pre-signal snapshot) leaves just the fourth test above red: the CONNECTED
|
|
60
|
+
// broker's detached descendant survives forever because the re-walk loses
|
|
61
|
+
// it the instant the broker exits.
|
|
62
|
+
import { test, before, after } from 'node:test';
|
|
63
|
+
import assert from 'node:assert/strict';
|
|
64
|
+
import { spawn } from 'node:child_process';
|
|
65
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
66
|
+
import { tmpdir } from 'node:os';
|
|
67
|
+
import { join } from 'node:path';
|
|
68
|
+
import { createHarness } from './helpers/harness.js';
|
|
69
|
+
import { isPidAlive, capturePidIdentities, killProcessTreePids, captureTeardownSnapshot } from '../canvas/pid.js';
|
|
70
|
+
import { recordPid } from '../canvas/canvas.js';
|
|
71
|
+
import { openDb } from '../canvas/db.js';
|
|
72
|
+
import { headlessBrokerHost } from '../runtime/host.js';
|
|
73
|
+
let h;
|
|
74
|
+
let root;
|
|
75
|
+
let scratch;
|
|
76
|
+
// Every pid this suite spawns as a fixture (leader OR descendant), tracked the
|
|
77
|
+
// INSTANT it is known — review finding 2: an assertion or setup failure
|
|
78
|
+
// partway through a test must not leak a `sleep 300` (or a leader waiting on
|
|
79
|
+
// one) past this file's own `after()`, which otherwise only disposes the
|
|
80
|
+
// harness/scratch dir and never touches these fabricated OS processes. Swept
|
|
81
|
+
// best-effort in `after()` below, in ADDITION to (never instead of) each
|
|
82
|
+
// test's real assertion that `headlessBrokerHost.teardown()` itself reaped them.
|
|
83
|
+
const spawnedPids = new Set();
|
|
84
|
+
function trackPid(pid) {
|
|
85
|
+
if (pid != null)
|
|
86
|
+
spawnedPids.add(pid);
|
|
87
|
+
}
|
|
88
|
+
before(async () => {
|
|
89
|
+
h = await createHarness({ headless: true, sessionPrefix: 'crtr-hostpg' });
|
|
90
|
+
root = h.spawnRoot('host-teardown-process-group suite root');
|
|
91
|
+
scratch = mkdtempSync(join(tmpdir(), 'crtr-hostpg-'));
|
|
92
|
+
});
|
|
93
|
+
after(async () => {
|
|
94
|
+
if (h !== undefined)
|
|
95
|
+
await h.dispose();
|
|
96
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
97
|
+
// Best-effort final sweep (review finding 2): a test that asserted before
|
|
98
|
+
// teardown finished reaping, or threw mid-fixture-setup, can leave a tracked
|
|
99
|
+
// pid (or its whole process GROUP, for the detached-leader fixtures) alive.
|
|
100
|
+
// Every signal here is best-effort — ESRCH (already gone) is swallowed — so
|
|
101
|
+
// this is a harmless no-op against a suite that reaped everything cleanly.
|
|
102
|
+
for (const pid of spawnedPids) {
|
|
103
|
+
try {
|
|
104
|
+
process.kill(pid, 'SIGKILL');
|
|
105
|
+
}
|
|
106
|
+
catch { /* already gone */ }
|
|
107
|
+
try {
|
|
108
|
+
process.kill(-pid, 'SIGKILL');
|
|
109
|
+
}
|
|
110
|
+
catch { /* already gone, or never its own group leader */ }
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
/** Wait for `leader` to write a pid (as a newline-terminated decimal number) to
|
|
114
|
+
* its stdout — with an actual FAILURE path (review finding 2): a bare
|
|
115
|
+
* `stdout.on('data', ...)` promise as used here previously had no `exit`/
|
|
116
|
+
* `close`/timeout rejection, so a leader that crashed or exited before ever
|
|
117
|
+
* writing its line left the `await` hanging FOREVER (no output, no timeout,
|
|
118
|
+
* the whole suite process wedged). This rejects on the leader exiting first,
|
|
119
|
+
* a stream error, OR a `timeoutMs` ceiling — whichever comes first — and
|
|
120
|
+
* always tears down its own listeners so it settles exactly once. */
|
|
121
|
+
async function readPidFromStdout(leader, label, timeoutMs = 5_000) {
|
|
122
|
+
return new Promise((resolvePid, reject) => {
|
|
123
|
+
let buf = '';
|
|
124
|
+
let settled = false;
|
|
125
|
+
const cleanup = () => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
leader.stdout?.off('data', onData);
|
|
128
|
+
leader.off('error', onError);
|
|
129
|
+
leader.off('exit', onExit);
|
|
130
|
+
};
|
|
131
|
+
const onData = (chunk) => {
|
|
132
|
+
if (settled)
|
|
133
|
+
return;
|
|
134
|
+
buf += chunk.toString('utf8');
|
|
135
|
+
const nl = buf.indexOf('\n');
|
|
136
|
+
if (nl === -1)
|
|
137
|
+
return;
|
|
138
|
+
settled = true;
|
|
139
|
+
cleanup();
|
|
140
|
+
resolvePid(Number.parseInt(buf.slice(0, nl), 10));
|
|
141
|
+
};
|
|
142
|
+
const onError = (err) => {
|
|
143
|
+
if (settled)
|
|
144
|
+
return;
|
|
145
|
+
settled = true;
|
|
146
|
+
cleanup();
|
|
147
|
+
reject(err);
|
|
148
|
+
};
|
|
149
|
+
const onExit = (code, signal) => {
|
|
150
|
+
if (settled)
|
|
151
|
+
return;
|
|
152
|
+
settled = true;
|
|
153
|
+
cleanup();
|
|
154
|
+
reject(new Error(`${label}: leader exited (code=${String(code)}, signal=${String(signal)}) before writing its pid`));
|
|
155
|
+
};
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
if (settled)
|
|
158
|
+
return;
|
|
159
|
+
settled = true;
|
|
160
|
+
cleanup();
|
|
161
|
+
reject(new Error(`${label}: timed out after ${timeoutMs}ms waiting for the leader to write its pid`));
|
|
162
|
+
}, timeoutMs);
|
|
163
|
+
leader.stdout?.on('data', onData);
|
|
164
|
+
leader.once('error', onError);
|
|
165
|
+
leader.once('exit', onExit);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** Spawn a fake "broker" (its own session/process group, like the real
|
|
169
|
+
* `launch()`) that in turn backgrounds a plain (non-detached, SAME-group)
|
|
170
|
+
* descendant — used only for the "dead broker, shared-group descendant"
|
|
171
|
+
* case, where the broker's own pgid (untouched by kernel reparenting) is
|
|
172
|
+
* what teardown must still reach. Hands back both pids once the descendant
|
|
173
|
+
* has actually started. */
|
|
174
|
+
async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sleep 300') {
|
|
175
|
+
const pidFile = join(scratch, `${tag}.pid`);
|
|
176
|
+
const leader = spawn('bash', ['-c', `(${descendantScript}) & echo $! > ${pidFile}; wait`], {
|
|
177
|
+
detached: true,
|
|
178
|
+
stdio: 'ignore',
|
|
179
|
+
});
|
|
180
|
+
if (leader.pid == null)
|
|
181
|
+
throw new Error('failed to spawn fake broker leader');
|
|
182
|
+
trackPid(leader.pid);
|
|
183
|
+
await h.waitFor(() => existsSync(pidFile), { label: `${tag}: descendant pid file written` });
|
|
184
|
+
const descendantPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10);
|
|
185
|
+
assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, `${tag}: parsed a real descendant pid`);
|
|
186
|
+
trackPid(descendantPid);
|
|
187
|
+
await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
|
|
188
|
+
return { leaderPid: leader.pid, descendantPid };
|
|
189
|
+
}
|
|
190
|
+
/** Spawn a fake "broker" that is ITSELF `detached: true` (own session/process
|
|
191
|
+
* group, exactly like the real `launch()`), which in turn spawns `descendantCmd`
|
|
192
|
+
* with `detached: true` TOO — mirroring the real pi bash tool's shell child
|
|
193
|
+
* (verified in `@earendil-works/pi-agent-core`'s `harness/env/nodejs.js`),
|
|
194
|
+
* which lands in a brand-new process group distinct from the broker's. The
|
|
195
|
+
* leader is a tiny inline Node script (not bash — macOS ships no `setsid`
|
|
196
|
+
* binary to force a real detach from a shell) that writes the descendant's
|
|
197
|
+
* pid to stdout once spawned, then idles. Hands back both pids once the
|
|
198
|
+
* descendant has actually started AND is confirmed to be in its OWN group. */
|
|
199
|
+
async function spawnBrokerWithDetachedDescendant(descendantCmd) {
|
|
200
|
+
const leaderScript = [
|
|
201
|
+
"const { spawn } = require('node:child_process');",
|
|
202
|
+
'const [cmd, ...args] = process.argv.slice(1);',
|
|
203
|
+
"const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });",
|
|
204
|
+
'child.unref();',
|
|
205
|
+
"process.stdout.write(String(child.pid) + '\\n');",
|
|
206
|
+
'setInterval(() => {}, 1000);',
|
|
207
|
+
].join('\n');
|
|
208
|
+
const leader = spawn(process.execPath, ['-e', leaderScript, ...descendantCmd], {
|
|
209
|
+
detached: true,
|
|
210
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
211
|
+
});
|
|
212
|
+
if (leader.pid == null)
|
|
213
|
+
throw new Error('failed to spawn fake detached broker leader');
|
|
214
|
+
leader.unref();
|
|
215
|
+
const leaderPid = leader.pid;
|
|
216
|
+
trackPid(leaderPid);
|
|
217
|
+
const descendantPid = await readPidFromStdout(leader, 'spawnBrokerWithDetachedDescendant');
|
|
218
|
+
trackPid(descendantPid);
|
|
219
|
+
assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, 'parsed a real detached descendant pid');
|
|
220
|
+
await h.waitFor(() => isPidAlive(descendantPid), { label: 'detached descendant alive before teardown' });
|
|
221
|
+
return { leaderPid, descendantPid };
|
|
222
|
+
}
|
|
223
|
+
/** `ps -o pgid= -p <pid>` as a number, or `null` if the pid has no row (already
|
|
224
|
+
* gone). Used only to prove structurally that a detached descendant's pgid
|
|
225
|
+
* differs from its broker's pid — never used to signal anything. */
|
|
226
|
+
async function readPgid(pid) {
|
|
227
|
+
const { execFileSync } = await import('node:child_process');
|
|
228
|
+
try {
|
|
229
|
+
const out = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' });
|
|
230
|
+
const parsed = Number.parseInt(out.trim(), 10);
|
|
231
|
+
return Number.isInteger(parsed) ? parsed : null;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Spawn a fake "broker" that is ITSELF `detached: true` (own session/process
|
|
238
|
+
* group, mirroring `launch()`) AND listens as a REAL unix-socket server on
|
|
239
|
+
* `sockPath` (the exact path `headlessBrokerHost.teardown()` connects to). It
|
|
240
|
+
* also spawns `descendantCmd` `detached: true` (own group, mirroring the pi
|
|
241
|
+
* bash tool's shell child) before it starts listening, so the descendant is
|
|
242
|
+
* discoverable via ppid at the moment teardown snapshots the tree. On the
|
|
243
|
+
* FIRST byte received over an accepted connection (i.e. the real `shutdown`
|
|
244
|
+
* frame `teardown()` writes) it calls `process.exit(0)` IMMEDIATELY —
|
|
245
|
+
* modeling a `dispose()` that exits without ever reaping its own detached
|
|
246
|
+
* descendant. Used only by the "review finding 1" CONNECTED-path regression
|
|
247
|
+
* test below. Hands back both pids once the descendant is confirmed alive. */
|
|
248
|
+
async function spawnBrokerWithDetachedDescendantAndSocket(sockPath, descendantCmd) {
|
|
249
|
+
const leaderScript = [
|
|
250
|
+
"const net = require('node:net');",
|
|
251
|
+
"const { spawn } = require('node:child_process');",
|
|
252
|
+
'const [sockPath, cmd, ...args] = process.argv.slice(1);',
|
|
253
|
+
"const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });",
|
|
254
|
+
'child.unref();',
|
|
255
|
+
'const server = net.createServer((conn) => {',
|
|
256
|
+
" conn.once('data', () => { process.exit(0); });",
|
|
257
|
+
" conn.once('end', () => { process.exit(0); });",
|
|
258
|
+
'});',
|
|
259
|
+
'server.listen(sockPath, () => {',
|
|
260
|
+
" process.stdout.write(String(child.pid) + '\\n');",
|
|
261
|
+
'});',
|
|
262
|
+
].join('\n');
|
|
263
|
+
const leader = spawn(process.execPath, ['-e', leaderScript, sockPath, ...descendantCmd], {
|
|
264
|
+
detached: true,
|
|
265
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
266
|
+
});
|
|
267
|
+
if (leader.pid == null)
|
|
268
|
+
throw new Error('failed to spawn fake connected-broker leader');
|
|
269
|
+
leader.unref();
|
|
270
|
+
const leaderPid = leader.pid;
|
|
271
|
+
trackPid(leaderPid);
|
|
272
|
+
const descendantPid = await readPidFromStdout(leader, 'spawnBrokerWithDetachedDescendantAndSocket');
|
|
273
|
+
trackPid(descendantPid);
|
|
274
|
+
assert.ok(Number.isInteger(descendantPid) && descendantPid > 0, 'parsed a real detached descendant pid (connected-broker case)');
|
|
275
|
+
await h.waitFor(() => isPidAlive(descendantPid), {
|
|
276
|
+
label: 'detached descendant alive before teardown (connected-broker case)',
|
|
277
|
+
});
|
|
278
|
+
return { leaderPid, descendantPid };
|
|
279
|
+
}
|
|
280
|
+
test('dead broker orphans a SAME-group descendant — teardown reaps it via the broker\'s own group signal', async () => {
|
|
281
|
+
const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('dead-broker');
|
|
282
|
+
// Simulate the exact GH repro: the broker itself is already gone (crashed /
|
|
283
|
+
// force-killed / wedged-then-reaped) BEFORE teardown ever runs — a plain
|
|
284
|
+
// `kill(leaderPid, 'SIGTERM')` would ESRCH here and touch nothing else.
|
|
285
|
+
process.kill(leaderPid, 'SIGKILL');
|
|
286
|
+
await h.waitFor(() => !isPidAlive(leaderPid), { label: 'fake broker leader is dead' });
|
|
287
|
+
assert.equal(isPidAlive(descendantPid), true, 'descendant survives its leader (orphaned, same pgid)');
|
|
288
|
+
const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
|
|
289
|
+
headlessBrokerHost.teardown(id);
|
|
290
|
+
await h.waitFor(() => !isPidAlive(descendantPid), {
|
|
291
|
+
timeoutMs: 6_000,
|
|
292
|
+
label: 'orphaned same-group descendant reaped by the broker group kill',
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
test('wedged (alive, unreachable) broker with a DETACHED descendant — process-group kill alone cannot reach it; ' +
|
|
296
|
+
'the ppid-tree-walk teardown does', async () => {
|
|
297
|
+
const { leaderPid, descendantPid } = await spawnBrokerWithDetachedDescendant(['sleep', '300']);
|
|
298
|
+
assert.equal(isPidAlive(leaderPid), true, 'fake broker leader alive before teardown');
|
|
299
|
+
assert.equal(isPidAlive(descendantPid), true, 'detached descendant alive before teardown');
|
|
300
|
+
// Structural proof that a bare process-GROUP kill of the broker can never
|
|
301
|
+
// reach this descendant: `process.kill(-pid, sig)` only reaches processes
|
|
302
|
+
// whose pgid === pid. The detached descendant called its own setsid(), so
|
|
303
|
+
// its pgid is its OWN pid, not the broker's — confirmed here without
|
|
304
|
+
// signaling anything.
|
|
305
|
+
const leaderPgid = await readPgid(leaderPid);
|
|
306
|
+
const descendantPgid = await readPgid(descendantPid);
|
|
307
|
+
assert.equal(leaderPgid, leaderPid, 'broker leader is its own group leader (detached launch)');
|
|
308
|
+
assert.equal(descendantPgid, descendantPid, 'descendant is ALSO its own group leader (detached spawn)');
|
|
309
|
+
assert.notEqual(descendantPgid, leaderPid, 'descendant pgid differs from the broker pid — a group kill of ' +
|
|
310
|
+
'-brokerPid structurally cannot reach it');
|
|
311
|
+
// No real broker is listening on this node's view.sock, so teardown's
|
|
312
|
+
// connect() always fails here — exactly the "wedged, unreachable" path.
|
|
313
|
+
// The broker is still alive at the OS level (not yet exited), so the
|
|
314
|
+
// ppid link from broker → descendant is intact for the tree walk.
|
|
315
|
+
const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
|
|
316
|
+
headlessBrokerHost.teardown(id);
|
|
317
|
+
await h.waitFor(() => !isPidAlive(leaderPid), { timeoutMs: 6_000, label: 'fake broker leader reaped' });
|
|
318
|
+
await h.waitFor(() => !isPidAlive(descendantPid), {
|
|
319
|
+
timeoutMs: 6_000,
|
|
320
|
+
label: 'DETACHED descendant reaped by the ppid-tree-walk kill',
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
test('a detached descendant that ignores SIGTERM only dies once the SIGKILL escalation fires', async () => {
|
|
324
|
+
// The leaf traps SIGTERM (ignores it) but cannot trap SIGKILL — proves the
|
|
325
|
+
// ladder genuinely reaches the final SIGKILL rung against a descendant's
|
|
326
|
+
// OWN group, not just the broker's.
|
|
327
|
+
const { leaderPid, descendantPid } = await spawnBrokerWithDetachedDescendant([
|
|
328
|
+
'bash',
|
|
329
|
+
'-c',
|
|
330
|
+
'trap "" TERM; sleep 300',
|
|
331
|
+
]);
|
|
332
|
+
const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
|
|
333
|
+
const start = Date.now();
|
|
334
|
+
headlessBrokerHost.teardown(id);
|
|
335
|
+
// Give the initial tree-wide SIGTERM a moment to land — the stubborn leaf
|
|
336
|
+
// must still be alive (it ignores TERM), proving SIGTERM alone doesn't reap it.
|
|
337
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
338
|
+
assert.equal(isPidAlive(descendantPid), true, 'SIGTERM-ignoring descendant survives the first rung');
|
|
339
|
+
await h.waitFor(() => !isPidAlive(descendantPid), {
|
|
340
|
+
timeoutMs: 8_000,
|
|
341
|
+
label: 'stubborn detached descendant reaped once the SIGKILL escalation fires',
|
|
342
|
+
});
|
|
343
|
+
assert.ok(Date.now() - start >= 1_500, 'the kill only lands after the SIGKILL grace window, not instantly');
|
|
344
|
+
});
|
|
345
|
+
test('CONNECTED broker accepts the shutdown frame and exits cleanly, but leaves a DETACHED descendant alive — ' +
|
|
346
|
+
'teardown must still reap it (review finding 1: post-shutdown liveness/escalation must reuse the ONE fixed ' +
|
|
347
|
+
"tree snapshot taken before the broker could exit, never re-walk `ps` by ppid afterward, since the exiting " +
|
|
348
|
+
"broker severs the descendant's ppid link the SAME tick)", async () => {
|
|
349
|
+
// Fabricate the node FIRST (placeholder pid) so we know its view.sock path
|
|
350
|
+
// before spawning the fake broker that must listen on that exact path.
|
|
351
|
+
const id = h.fabricateBrokerNode({ parent: root, pi_pid: null, status: 'active' });
|
|
352
|
+
const sockPath = h.brokerSock(id);
|
|
353
|
+
const { leaderPid, descendantPid } = await spawnBrokerWithDetachedDescendantAndSocket(sockPath, ['sleep', '300']);
|
|
354
|
+
recordPid(id, leaderPid);
|
|
355
|
+
assert.equal(isPidAlive(leaderPid), true, 'fake connected broker leader alive before teardown');
|
|
356
|
+
assert.equal(isPidAlive(descendantPid), true, 'detached descendant alive before teardown');
|
|
357
|
+
// teardown() connects, writes the shutdown frame, and the fake broker
|
|
358
|
+
// exits the INSTANT it receives that first byte — WITHOUT reaping its own
|
|
359
|
+
// detached descendant (modeling a dispose() that never reaches the pi bash
|
|
360
|
+
// tool's shell child). This is the exact connected/shutdown path finding 1
|
|
361
|
+
// covers: a re-walk-by-ppid AFTER that exit would find the descendant
|
|
362
|
+
// reparented away from `leaderPid` and conclude the tree is dead, silently
|
|
363
|
+
// skipping escalation — this descendant would then survive FOREVER.
|
|
364
|
+
headlessBrokerHost.teardown(id);
|
|
365
|
+
await h.waitFor(() => !isPidAlive(leaderPid), {
|
|
366
|
+
timeoutMs: 6_000,
|
|
367
|
+
label: 'fake connected broker leader exited (accepted the shutdown frame)',
|
|
368
|
+
});
|
|
369
|
+
await h.waitFor(() => !isPidAlive(descendantPid), {
|
|
370
|
+
timeoutMs: 8_000,
|
|
371
|
+
label: 'descendant of a CLEANLY-EXITED connected broker reaped by the fixed-snapshot escalation',
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
test('a FAILED initial identity probe must not be mistaken for "pid already gone" — killProcessTreePids still ' +
|
|
375
|
+
'signals every real live pid in the snapshot (final review, crouter#98 signal-safety finding: ' +
|
|
376
|
+
'`capturePidIdentities` collapsed "ps succeeded, no row" and "ps probe itself failed" into the same empty ' +
|
|
377
|
+
'map, so a transient failure of the INITIAL capture made every real live pid look "reused" against a ' +
|
|
378
|
+
'later-successful current probe, and `killProcessTreePids` skipped signaling all of them — silently ' +
|
|
379
|
+
"disabling the entire #98 reap. This must fail against that collapse-bug behavior and pass once a failed " +
|
|
380
|
+
'probe is reported distinctly (`null`) instead of an empty map.)', async () => {
|
|
381
|
+
const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('probe-failure');
|
|
382
|
+
const tree = [leaderPid, descendantPid];
|
|
383
|
+
// Force `capturePidIdentities`'s `ps` spawn to fail deterministically —
|
|
384
|
+
// no binary named `ps` is resolvable on this PATH — standing in for a
|
|
385
|
+
// transient probe failure (timeout, spawn error) at the exact moment
|
|
386
|
+
// `teardown()` takes its INITIAL identity snapshot. Mutating
|
|
387
|
+
// `process.env.PATH` is safe here only because `capturePidIdentities` is
|
|
388
|
+
// fully synchronous (`spawnSync`) and this file's tests run sequentially,
|
|
389
|
+
// so the broken PATH is never visible to any other in-flight test.
|
|
390
|
+
const origPath = process.env.PATH;
|
|
391
|
+
let identities;
|
|
392
|
+
try {
|
|
393
|
+
process.env.PATH = '/nonexistent-crtr-test-path';
|
|
394
|
+
identities = capturePidIdentities(tree);
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
process.env.PATH = origPath;
|
|
398
|
+
}
|
|
399
|
+
assert.equal(identities, null, 'a failed ps probe reports null, never an empty-but-valid map');
|
|
400
|
+
assert.equal(isPidAlive(leaderPid), true, 'fake broker leader alive before the guarded signal');
|
|
401
|
+
assert.equal(isPidAlive(descendantPid), true, 'shared-group descendant alive before the guarded signal');
|
|
402
|
+
// The current probe inside killProcessTreePids now runs with PATH already
|
|
403
|
+
// restored, so it succeeds — this is the exact shape of the bug: a failed
|
|
404
|
+
// INITIAL capture followed by a successful current one. A pre-fix
|
|
405
|
+
// `identities` here would be an empty (not null) map, which the collapse
|
|
406
|
+
// bug reads as "both pids were already dead at snapshot time," and then
|
|
407
|
+
// reads the current probe's real (alive) identities as proof of reuse —
|
|
408
|
+
// skipping every signal below and leaving both pids alive forever.
|
|
409
|
+
killProcessTreePids(tree, 'SIGTERM', identities);
|
|
410
|
+
await h.waitFor(() => !isPidAlive(leaderPid), {
|
|
411
|
+
timeoutMs: 6_000,
|
|
412
|
+
label: 'broker leader signaled despite the failed initial identity probe',
|
|
413
|
+
});
|
|
414
|
+
await h.waitFor(() => !isPidAlive(descendantPid), {
|
|
415
|
+
timeoutMs: 6_000,
|
|
416
|
+
label: 'shared-group descendant signaled despite the failed initial identity probe',
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
test('captureTeardownSnapshot refuses a pid whose CURRENT identity differs from its recorded LAUNCH-time baseline ' +
|
|
420
|
+
'(final review, Fix 1: the guard must catch a pid ALREADY reused before teardown ever starts, not just reuse ' +
|
|
421
|
+
'across the escalation window) — tree comes back EMPTY, so nothing downstream ever signals the stranger', async () => {
|
|
422
|
+
const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('snapshot-mismatch');
|
|
423
|
+
// A deliberately WRONG "launch-time" identity — no real `ps lstart`/
|
|
424
|
+
// `/proc` starttime pair for a genuinely alive process can ever equal
|
|
425
|
+
// this string, so this stands in for "the OS recycled this pid for an
|
|
426
|
+
// unrelated process since launch" without needing to actually wait out a
|
|
427
|
+
// real pid-recycle.
|
|
428
|
+
const snapshot = captureTeardownSnapshot(leaderPid, 'deliberately-mismatched-launch-identity');
|
|
429
|
+
assert.equal(snapshot.reused, true, 'a real, alive, differently-identified pid reports as reused');
|
|
430
|
+
assert.deepEqual(snapshot.tree, [], 'a reused root yields an EMPTY tree — no descendant walk from a stranger');
|
|
431
|
+
assert.equal(snapshot.identities, null, 'no identity map for a poisoned/reused snapshot');
|
|
432
|
+
// Sanity: the SAME pid with its OWN real current identity as the baseline
|
|
433
|
+
// is never flagged reused, and its descendant tree resolves normally.
|
|
434
|
+
const realIdentity = capturePidIdentities([leaderPid])?.get(leaderPid) ?? null;
|
|
435
|
+
assert.notEqual(realIdentity, null, 'the real launch identity capture succeeds for a genuinely alive pid');
|
|
436
|
+
const matched = captureTeardownSnapshot(leaderPid, realIdentity);
|
|
437
|
+
assert.equal(matched.reused, false, 'a matching baseline is never flagged reused');
|
|
438
|
+
assert.ok(matched.tree.includes(leaderPid) && matched.tree.includes(descendantPid), 'the real tree is walked normally when the baseline matches');
|
|
439
|
+
// Never signaled by this test — the `after()` sweep is the safety net.
|
|
440
|
+
assert.equal(isPidAlive(leaderPid), true, 'unsignaled leader is still alive');
|
|
441
|
+
assert.equal(isPidAlive(descendantPid), true, 'unsignaled descendant is still alive');
|
|
442
|
+
});
|
|
443
|
+
test('end-to-end: headlessBrokerHost.teardown() honors a launch-time identity mismatch and never signals the ' +
|
|
444
|
+
'stand-in "reused pid" tree at all, even after both the SIGTERM and SIGKILL escalation grace windows elapse', async () => {
|
|
445
|
+
const { leaderPid, descendantPid } = await spawnBrokerWithSharedGroupDescendant('teardown-mismatch');
|
|
446
|
+
const id = h.fabricateBrokerNode({ parent: root, pi_pid: leaderPid, status: 'active' });
|
|
447
|
+
// recordPid captures the REAL current identity for leaderPid (genuinely
|
|
448
|
+
// alive right now, so this succeeds) — then this corrupts JUST the stored
|
|
449
|
+
// identity column to simulate "the recorded launch-time baseline no
|
|
450
|
+
// longer matches this pid's CURRENT owner", standing in for an actual OS
|
|
451
|
+
// pid-recycle between this node's launch and this teardown() call.
|
|
452
|
+
recordPid(id, leaderPid);
|
|
453
|
+
openDb()
|
|
454
|
+
.prepare('UPDATE nodes SET pi_pid_identity = ? WHERE node_id = ?')
|
|
455
|
+
.run('deliberately-mismatched-launch-identity', id);
|
|
456
|
+
headlessBrokerHost.teardown(id);
|
|
457
|
+
// No real broker listens on this fabricated node's view.sock, so
|
|
458
|
+
// teardown's connect-error escalation path is the one in play. Wait past
|
|
459
|
+
// BOTH the SIGTERM and SIGKILL grace windows (2s + 2s, host.ts) it would
|
|
460
|
+
// use if it were (wrongly) signaling this tree — if the guard failed, this
|
|
461
|
+
// fixture would be dead well before this wait elapses.
|
|
462
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
463
|
+
assert.equal(isPidAlive(leaderPid), true, 'mismatched-identity leader was NEVER signaled — still alive');
|
|
464
|
+
assert.equal(isPidAlive(descendantPid), true, 'its descendant was NEVER signaled either — still alive');
|
|
465
|
+
});
|
|
@@ -76,6 +76,20 @@ test('deletes the oldest terminal nodes beyond max(cap, liveCount)', () => {
|
|
|
76
76
|
assert.ok(!existsSync(nodeDir(id)), `${id} dir should be removed`);
|
|
77
77
|
}
|
|
78
78
|
});
|
|
79
|
+
test('a non-terminal node with a dead pid is never a prune candidate (issue #114)', () => {
|
|
80
|
+
// An active row whose broker crashed: status stays 'active' but pi_pid is
|
|
81
|
+
// NULL (never recorded), so isPidAlive would be false. It must still be
|
|
82
|
+
// protected — crash-recovery, not the count-based pruner, owns this row —
|
|
83
|
+
// even though it's the oldest node and cap is exceeded by newer terminal rows.
|
|
84
|
+
createNode(node('crashed-active', { status: 'active', created: at(100) }));
|
|
85
|
+
for (let i = 0; i < 5; i++) {
|
|
86
|
+
createNode(node(`term-${i}`, { status: 'dead', created: at(i) }));
|
|
87
|
+
}
|
|
88
|
+
const result = pruneToLimit({ maxNodes: 3 });
|
|
89
|
+
assert.ok(!result.pruned.some((p) => p.node_id === 'crashed-active'), 'a crashed-but-active node must not be selected for pruning');
|
|
90
|
+
assert.ok(getRow('crashed-active') !== null, 'crashed-active row must survive');
|
|
91
|
+
assert.ok(existsSync(nodeDir('crashed-active')), 'crashed-active dir must survive');
|
|
92
|
+
});
|
|
79
93
|
test('--dry-run reports candidates but deletes nothing', () => {
|
|
80
94
|
for (let i = 0; i < 5; i++) {
|
|
81
95
|
createNode(node(`term-${i}`, { status: 'dead', created: at(i) }));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/review-model-floor.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Issue #111 — review work must not be forced down to haiku/light by an
|
|
4
|
+
// explicit `--model light`. Review personas already default to strong; this
|
|
5
|
+
// locks the launch-spec choke point so review-labeled work keeps that floor
|
|
6
|
+
// even when a spawning agent chooses a cheaper override.
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { buildLaunchSpec, normalizeModel } from '../runtime/launch.js';
|
|
10
|
+
function spec(kind, model) {
|
|
11
|
+
return buildLaunchSpec(kind, 'base', {
|
|
12
|
+
lifecycle: 'terminal',
|
|
13
|
+
hasManager: true,
|
|
14
|
+
...(model !== undefined ? { model } : {}),
|
|
15
|
+
}).launch.model;
|
|
16
|
+
}
|
|
17
|
+
test('review ignores a light override and keeps its strong persona floor', () => {
|
|
18
|
+
assert.equal(spec('review', 'light'), normalizeModel('openai/strong'));
|
|
19
|
+
assert.equal(spec('review', 'haiku'), normalizeModel('openai/strong'));
|
|
20
|
+
});
|
|
21
|
+
test('plan reviewer sub-kinds also keep their strong persona floor', () => {
|
|
22
|
+
assert.equal(spec('plan/reviewers/security', 'medium'), normalizeModel('openai/strong'));
|
|
23
|
+
assert.equal(spec('plan/reviewers/requirements-coverage', 'light'), normalizeModel('openai/strong'));
|
|
24
|
+
});
|
|
25
|
+
test('review still permits equal-or-stronger explicit models', () => {
|
|
26
|
+
assert.equal(spec('review', 'strong'), normalizeModel('strong'));
|
|
27
|
+
assert.equal(spec('review', 'ultra'), normalizeModel('ultra'));
|
|
28
|
+
});
|
|
29
|
+
test('explore remains cheap by default and may explicitly run light', () => {
|
|
30
|
+
assert.equal(spec('explore'), normalizeModel('anthropic/light'));
|
|
31
|
+
assert.equal(spec('explore', 'light'), normalizeModel('light'));
|
|
32
|
+
});
|
|
@@ -117,11 +117,18 @@ test('resumeArgs selects nothing (falls through to fresh) when the recorded file
|
|
|
117
117
|
});
|
|
118
118
|
assert.deepEqual(resumeArgs(m, true), {});
|
|
119
119
|
});
|
|
120
|
-
test('resumeArgs
|
|
120
|
+
test('resumeArgs CYCLES the existing file (same path + newCycle) on a no-resume (refresh) revive', () => {
|
|
121
121
|
mkdirSync(home, { recursive: true });
|
|
122
122
|
const sessionFile = join(home, 'resume-args-refresh.jsonl');
|
|
123
123
|
writeFileSync(sessionFile, '{}\n', 'utf8');
|
|
124
124
|
const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: sessionFile });
|
|
125
|
+
assert.deepEqual(resumeArgs(m, false), { resumeSessionPath: sessionFile, newCycle: true });
|
|
126
|
+
});
|
|
127
|
+
test('resumeArgs selects neither source on a refresh revive with no `.jsonl` on disk yet', () => {
|
|
128
|
+
const m = node('n', {
|
|
129
|
+
pi_session_id: 'uuid-123',
|
|
130
|
+
pi_session_file: join(home, 'refresh-never-written.jsonl'),
|
|
131
|
+
});
|
|
125
132
|
assert.deepEqual(resumeArgs(m, false), {});
|
|
126
133
|
});
|
|
127
134
|
// ---------------------------------------------------------------------------
|
|
@@ -230,18 +237,22 @@ test('reviveNode PROCEEDS when the broker engine is DOWN (dead pid) — cycle co
|
|
|
230
237
|
// engine) launch, so the bump is observable instantly; the throwaway broker is
|
|
231
238
|
// killed by dispose().
|
|
232
239
|
const sessionFile = join(home, 'sessions', 'crtr-revive-proceed.jsonl');
|
|
240
|
+
const stalePid = deadPid();
|
|
233
241
|
mkdirSync(join(home, 'sessions'), { recursive: true });
|
|
234
242
|
writeFileSync(sessionFile, '{}\n', 'utf8');
|
|
235
243
|
const M = h.fabricateBrokerNode({
|
|
236
244
|
status: 'active',
|
|
237
245
|
intent: null,
|
|
238
|
-
pi_pid:
|
|
246
|
+
pi_pid: stalePid,
|
|
239
247
|
pi_session_id: 'uuid-1',
|
|
240
248
|
pi_session_file: sessionFile,
|
|
241
249
|
});
|
|
242
250
|
assert.equal(h.node(M).cycles ?? 0, 0, 'fabricated at cycle 0 (no revive yet)');
|
|
243
251
|
reviveNode(M, { resume: true });
|
|
244
|
-
|
|
252
|
+
const after = h.node(M);
|
|
253
|
+
assert.equal(after.cycles, 1, 'a real (non-guard) revive bumped the cycle counter to 1');
|
|
254
|
+
assert.notEqual(after.pi_pid, stalePid, 'revive records the fresh broker pid immediately, not the stale dead pid');
|
|
255
|
+
assert.equal(after.pi_pid == null ? false : after.pi_pid > 0, true, 'revive leaves no pid=null boot window for the daemon to false-crash');
|
|
245
256
|
}
|
|
246
257
|
finally {
|
|
247
258
|
await h.dispose();
|
|
@@ -257,10 +268,11 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
|
|
|
257
268
|
// pi_session_id — a leftover id would be misread as a stranded relaunch and
|
|
258
269
|
// retried forever instead of surfaced as a boot failure.
|
|
259
270
|
const missingFile = join(home, 'sessions', 'never-written.jsonl');
|
|
271
|
+
const stalePid = deadPid();
|
|
260
272
|
const M = h.fabricateBrokerNode({
|
|
261
273
|
status: 'active',
|
|
262
274
|
intent: null,
|
|
263
|
-
pi_pid:
|
|
275
|
+
pi_pid: stalePid,
|
|
264
276
|
pi_session_id: 'uuid-stale',
|
|
265
277
|
pi_session_file: missingFile,
|
|
266
278
|
});
|
|
@@ -269,6 +281,8 @@ test('reviveNode fresh-fallback clears the stale session identity (Major-3: no p
|
|
|
269
281
|
const after = h.node(M);
|
|
270
282
|
assert.equal(after.pi_session_id, null, 'stale pi_session_id cleared before the fresh launch');
|
|
271
283
|
assert.equal(after.pi_session_file, null, 'stale pi_session_file cleared before the fresh launch');
|
|
284
|
+
assert.notEqual(after.pi_pid, stalePid, 'fresh fallback records the new broker pid immediately');
|
|
285
|
+
assert.equal(after.pi_pid == null ? false : after.pi_pid > 0, true, 'fresh fallback leaves no pid=null boot window for the daemon to false-crash');
|
|
272
286
|
}
|
|
273
287
|
finally {
|
|
274
288
|
await h.dispose();
|