pi-crew 0.11.38 → 0.11.39
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/CHANGELOG.md +54 -0
- package/dist/index.mjs +522 -518
- package/package.json +2 -2
- package/skills/real-test-pi-crew/SKILL.md +9 -1
- package/src/config/env-allowlist-registry.ts +14 -1
- package/src/config/env-vars.ts +12 -0
- package/src/extension/registration/lifecycle-handlers.ts +12 -0
- package/src/extension/team-tool/doctor.ts +125 -1
- package/src/runtime/child-pi/child-pi-kill.ts +68 -2
- package/src/runtime/child-pi/child-pi-spawn.ts +4 -0
- package/src/runtime/heartbeat/worker-heartbeat.ts +4 -0
- package/src/runtime/process/orphan-reaper.ts +197 -22
- package/src/runtime/process/proc-stat.ts +8 -0
- package/src/runtime/process/reap-group.ts +499 -0
- package/src/runtime/process/run-descendant-sweep.ts +744 -0
- package/src/runtime/recovery/crash-recovery.ts +202 -0
- package/src/runtime/rpc/frame-client.ts +21 -1
- package/src/runtime/stale-reconciler.ts +343 -2
- package/src/runtime/task-runner/child-executor.ts +12 -1
- package/src/runtime/task-runner/state-helpers.ts +28 -1
- package/src/state/stores/state-store.ts +8 -0
- package/src/state/types.ts +42 -0
|
@@ -24,14 +24,17 @@ import { recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts";
|
|
|
24
24
|
import { isWorkerHeartbeatStale } from "../heartbeat/worker-heartbeat.ts";
|
|
25
25
|
import { terminateLiveAgentsForRun } from "../live-session/live-agent-manager.ts";
|
|
26
26
|
import type { ManifestCache } from "../manifest-cache.ts";
|
|
27
|
+
import { buildProcSnapshot, type ProcSnapshot } from "../process/run-descendant-sweep.ts";
|
|
27
28
|
import { checkProcessLiveness } from "../process-status.ts";
|
|
28
29
|
import { mapConcurrent } from "../scheduling/parallel-utils.ts";
|
|
29
30
|
import {
|
|
31
|
+
drainReapEscalations,
|
|
30
32
|
hasLiveRunClaim,
|
|
31
33
|
isIntentionalWait,
|
|
32
34
|
isPlanApprovalPendingEffective,
|
|
33
35
|
type ReconcileResult,
|
|
34
36
|
reconcileStaleRun,
|
|
37
|
+
sweepTerminalRunDescendants,
|
|
35
38
|
} from "../stale-reconciler.ts";
|
|
36
39
|
|
|
37
40
|
export interface RecoveryPlan {
|
|
@@ -1023,12 +1026,156 @@ export function applyReconcileAtOpenFastPath(
|
|
|
1023
1026
|
};
|
|
1024
1027
|
}
|
|
1025
1028
|
|
|
1029
|
+
/**
|
|
1030
|
+
* Wave 11b.2: run statuses whose descendants are fair game for the startup
|
|
1031
|
+
* sweep. Deliberately NARROWER than `TEAM_TERMINAL_RUN_STATUSES`, which also
|
|
1032
|
+
* counts `blocked`: a blocked run is intentionally parked (plan approval / ask)
|
|
1033
|
+
* and is enumerated by the reconcile lane, so its descendants must not be swept
|
|
1034
|
+
* here. `queued`/`planning`/`running` never appear either — those runs may
|
|
1035
|
+
* legitimately own live processes.
|
|
1036
|
+
*/
|
|
1037
|
+
const TERMINAL_RUN_STATUSES_FOR_SWEEP: ReadonlySet<TeamRunManifest["status"]> = new Set(["completed", "failed", "cancelled"]);
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Wave 11b.2: hard cap on terminal runs swept in ONE pass. Each visit costs a
|
|
1041
|
+
* /proc enumeration (plus a lock + two manifest reads), so an install with
|
|
1042
|
+
* hundreds of historical runs must not turn startup into a /proc storm; the
|
|
1043
|
+
* remainder are picked up on later passes (startup + the periodic reconcile),
|
|
1044
|
+
* newest first, which is also where leaks are most likely to still exist.
|
|
1045
|
+
*/
|
|
1046
|
+
const MAX_UNSWEPT_TERMINAL_RUNS_PER_PASS = 25;
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Wave 11b.3 (grace window): a terminal run is only swept once its `updatedAt`
|
|
1050
|
+
* is at least this old. `status` flips to `completed` the moment the last task
|
|
1051
|
+
* finishes, while the run's mux panes / live sessions can still be attached for
|
|
1052
|
+
* a while afterwards — the operator may be reading that output. Sweeping
|
|
1053
|
+
* immediately would kill processes the operator is still using, so the lane
|
|
1054
|
+
* waits one minute. A run that is still inside the window is NOT stamped: it
|
|
1055
|
+
* simply stays eligible and is picked up by a later pass (startup + the periodic
|
|
1056
|
+
* reconcile), which is exactly the reachability the 11b.2 marker provides.
|
|
1057
|
+
*/
|
|
1058
|
+
const TERMINAL_SWEEP_MIN_AGE_MS = 60_000;
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Wave 11b.3 (old-run starvation): how deep the terminal lane reads the manifest
|
|
1062
|
+
* cache. `list(50)` (the call the reconcile lane uses) is a top-N by `createdAt`
|
|
1063
|
+
* — with 50 newer runs in between, the run that actually leaked (the 10-22
|
|
1064
|
+
* incident: completed a day before its grandchildren died) would never be seen.
|
|
1065
|
+
* 200 is a per-lane window only; `DEFAULT_CACHE.manifestMaxEntries` and the
|
|
1066
|
+
* global `list()` default stay untouched.
|
|
1067
|
+
*/
|
|
1068
|
+
const TERMINAL_SWEEP_CACHE_WINDOW = 200;
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Wave 11b.4 (HIGH-2): how long a run marked `descendantSweepPending` waits
|
|
1072
|
+
* before the lane tries it again. A pending flag means the sweep PROVED
|
|
1073
|
+
* descendants it could not signal (a live unmatched group leader, a candidate
|
|
1074
|
+
* with no usable pgid, a refused group reap, an interactive tty) — the leak is
|
|
1075
|
+
* still out there, but the blocker is usually another live process, so retrying
|
|
1076
|
+
* on the very next pass would burn a /proc scan per pass for as long as that
|
|
1077
|
+
* process lives. Ten minutes is short enough that a real leak is reaped while
|
|
1078
|
+
* the operator is still in the same session, and long enough that a stuck run
|
|
1079
|
+
* cannot dominate the 25-per-pass budget.
|
|
1080
|
+
*/
|
|
1081
|
+
const TERMINAL_SWEEP_RETRY_MS = 10 * 60_000;
|
|
1082
|
+
|
|
1083
|
+
function isTerminalRunStatus(status: TeamRunManifest["status"]): boolean {
|
|
1084
|
+
return TERMINAL_RUN_STATUSES_FOR_SWEEP.has(status);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Wave 11b.2 + 11b.4: is this TERMINAL run still entitled to a sweep attempt?
|
|
1089
|
+
*
|
|
1090
|
+
* Never swept ⇒ yes. Swept and clean ⇒ no (that is what the marker is for).
|
|
1091
|
+
* Swept but `descendantSweepPending` ⇒ yes again, once the bounded retry
|
|
1092
|
+
* interval has elapsed — otherwise a run whose candidates were all skipped
|
|
1093
|
+
* would never be looked at again and 11b.4's whole point (a real leak must stay
|
|
1094
|
+
* reachable) would be lost.
|
|
1095
|
+
*/
|
|
1096
|
+
function isTerminalSweepEligible(manifest: TeamRunManifest, now: number): boolean {
|
|
1097
|
+
if (!isTerminalRunStatus(manifest.status)) return false;
|
|
1098
|
+
if (manifest.descendantSweepAt === undefined) return true;
|
|
1099
|
+
if (manifest.descendantSweepPending !== true) return false;
|
|
1100
|
+
const sweptAt = Date.parse(manifest.descendantSweepAt);
|
|
1101
|
+
return Number.isFinite(sweptAt) && now - sweptAt > TERMINAL_SWEEP_RETRY_MS;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* Epoch ms of the run's last state change (`updatedAt`), falling back to
|
|
1106
|
+
* `createdAt`. `TeamRunManifest` has no `completedAt` (verified in
|
|
1107
|
+
* state/types.ts) and `updatedAt` is the field every status write refreshes, so
|
|
1108
|
+
* it is the correct "when did this run become terminal" proxy. A manifest whose
|
|
1109
|
+
* timestamps are both unparseable is treated as ANCIENT (0) — it must not starve
|
|
1110
|
+
* a leak just because its clock field is broken.
|
|
1111
|
+
*/
|
|
1112
|
+
function terminalSinceMs(manifest: TeamRunManifest): number {
|
|
1113
|
+
const updated = Date.parse(manifest.updatedAt);
|
|
1114
|
+
if (Number.isFinite(updated)) return updated;
|
|
1115
|
+
const created = Date.parse(manifest.createdAt);
|
|
1116
|
+
return Number.isFinite(created) ? created : 0;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/**
|
|
1120
|
+
* Wave 11b.2 + 11b.3: terminal runs whose descendant sweep marker is absent,
|
|
1121
|
+
* OLDEST first, capped at `MAX_UNSWEPT_TERMINAL_RUNS_PER_PASS`.
|
|
1122
|
+
*
|
|
1123
|
+
* This list is what makes the sweep reachable after the fact: the pre-11b.2
|
|
1124
|
+
* enumeration (`status === running | blocked`) structurally could not see a run
|
|
1125
|
+
* that had already persisted as `completed` — the shape of the incident run,
|
|
1126
|
+
* whose leaked grandchildren outlived it by a day. It is intentionally NOT
|
|
1127
|
+
* filtered by `currentSessionId`: a terminal run has no live claim to respect
|
|
1128
|
+
* (the marker, checked again under the run lock, is the idempotency guard), and
|
|
1129
|
+
* the sweep itself refuses to signal anything it cannot prove descends from
|
|
1130
|
+
* THIS run with a dead recorded parent.
|
|
1131
|
+
*
|
|
1132
|
+
* Ordering (11b.3): OLDEST `updatedAt` first, deliberately the opposite of the
|
|
1133
|
+
* reconcile lane. The cap is 25 /proc scans per pass, so whatever order is
|
|
1134
|
+
* chosen decides which leaks STARVE — and a leak from an old run is the one
|
|
1135
|
+
* that has been burning memory longest (the incident ran for 24 h). Newest-first
|
|
1136
|
+
* would let a steady stream of fresh runs push an old leak out of the cap
|
|
1137
|
+
* forever; oldest-first guarantees every eligible run is visited within
|
|
1138
|
+
* `ceil(eligible / 25)` passes.
|
|
1139
|
+
*
|
|
1140
|
+
* Age gate (11b.3): a run whose `updatedAt` is younger than
|
|
1141
|
+
* `TERMINAL_SWEEP_MIN_AGE_MS` is skipped and left UNSTAMPED (see the constant).
|
|
1142
|
+
*
|
|
1143
|
+
* Retry gate (11b.4, HIGH-2): a stamped run comes back only while it still
|
|
1144
|
+
* carries `descendantSweepPending` AND `TERMINAL_SWEEP_RETRY_MS` has elapsed
|
|
1145
|
+
* since the stamp — see `isTerminalSweepEligible`. Selection is still
|
|
1146
|
+
* oldest-first and still capped, so a pending run competes for the same 25-scan
|
|
1147
|
+
* budget as a never-swept one rather than getting a lane of its own.
|
|
1148
|
+
*/
|
|
1149
|
+
function selectUnsweptTerminalRunIds(manifestCache: ManifestCache, now = Date.now()): string[] {
|
|
1150
|
+
return manifestCache
|
|
1151
|
+
.list(TERMINAL_SWEEP_CACHE_WINDOW)
|
|
1152
|
+
.filter((manifest) => isTerminalSweepEligible(manifest, now))
|
|
1153
|
+
.map((manifest) => ({ runId: manifest.runId, terminalSinceMs: terminalSinceMs(manifest) }))
|
|
1154
|
+
.filter((entry) => now - entry.terminalSinceMs >= TERMINAL_SWEEP_MIN_AGE_MS)
|
|
1155
|
+
.sort((a, b) => a.terminalSinceMs - b.terminalSinceMs)
|
|
1156
|
+
.slice(0, MAX_UNSWEPT_TERMINAL_RUNS_PER_PASS)
|
|
1157
|
+
.map((entry) => entry.runId);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1026
1160
|
export async function reconcileAllStaleRuns(
|
|
1027
1161
|
cwd: string,
|
|
1028
1162
|
manifestCache: ManifestCache,
|
|
1029
1163
|
now = Date.now(),
|
|
1030
1164
|
currentSessionId?: string,
|
|
1031
1165
|
): Promise<ReconcileResult[]> {
|
|
1166
|
+
// Wave 11b.2 (F7 reachability): startup reconciliation used to enumerate
|
|
1167
|
+
// `running | blocked` only, so a run that had ALREADY persisted as terminal —
|
|
1168
|
+
// the common case the next morning — never reached `reconcileStaleRun` and its
|
|
1169
|
+
// leaked descendants (the 390 MB / 24 h bash grandchildren behind
|
|
1170
|
+
// team_20261022110510) were never reaped. A leak outlives its run, so the
|
|
1171
|
+
// sweep must be reachable from a LATER pass: every terminal run that has not
|
|
1172
|
+
// been swept yet (`manifest.descendantSweepAt` absent) is enumerated here and
|
|
1173
|
+
// swept once, under its own run lock, in a lane that never runs the stale
|
|
1174
|
+
// verdict tree (a terminal run is not stale — it is finished).
|
|
1175
|
+
// Wave 11b.3 adds the two bounds: the grace window (`updatedAt` at least a
|
|
1176
|
+
// minute old, so a just-finished run keeps its panes) and the deeper cache
|
|
1177
|
+
// window (oldest first), both documented on the constants above.
|
|
1178
|
+
const terminalRunIds = selectUnsweptTerminalRunIds(manifestCache, now);
|
|
1032
1179
|
// Capture runIds to reconcile BEFORE acquiring locks — avoids TOCTOU between cache iteration and lock acquisition.
|
|
1033
1180
|
const runIds = manifestCache
|
|
1034
1181
|
.list(50)
|
|
@@ -1123,6 +1270,11 @@ export async function reconcileAllStaleRuns(
|
|
|
1123
1270
|
out.push(result);
|
|
1124
1271
|
}
|
|
1125
1272
|
});
|
|
1273
|
+
// Wave 11a review (MAJOR #3): the SYNC reconcile arms a group SIGKILL
|
|
1274
|
+
// escalation it cannot await. This async owner awaits it OUTSIDE the run
|
|
1275
|
+
// lock (so the lock hold time is unchanged) — the sweep must not resolve
|
|
1276
|
+
// while a SIGKILL is still pending delivery.
|
|
1277
|
+
await drainReapEscalations();
|
|
1126
1278
|
return out;
|
|
1127
1279
|
} catch (error) {
|
|
1128
1280
|
logInternalError("crash-recovery.reconcileStaleRuns", error, `runId=${runId}`, "warn");
|
|
@@ -1130,6 +1282,56 @@ export async function reconcileAllStaleRuns(
|
|
|
1130
1282
|
}
|
|
1131
1283
|
});
|
|
1132
1284
|
const results = perRun.flat();
|
|
1285
|
+
// Wave 11b.2: terminal-run descendant sweep lane. Deliberately SEPARATE from
|
|
1286
|
+
// the reconcile lane above: a terminal run must never enter the stale verdict
|
|
1287
|
+
// tree (`updateRunStatus`, task repair, `crew.run.reconciled_stale`), it only
|
|
1288
|
+
// needs its leaked descendants reaped once. Same per-run error isolation and
|
|
1289
|
+
// per-run lock as the reconcile lane; contributes no ReconcileResult.
|
|
1290
|
+
if (terminalRunIds.length > 0) {
|
|
1291
|
+
logInternalError(
|
|
1292
|
+
"crash-recovery.terminal-descendant-sweep",
|
|
1293
|
+
new Error(`sweeping descendants of ${terminalRunIds.length} unswept terminal run(s)`),
|
|
1294
|
+
terminalRunIds.join(","),
|
|
1295
|
+
"debug",
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
// Wave 11b.4 (LOW-4): ONE /proc snapshot for the whole lane. Built lazily —
|
|
1299
|
+
// a pass whose every run bails out below (already clean, resumed, inside the
|
|
1300
|
+
// grace window) never pays for it. Before 11b.4 each run built its own, so a
|
|
1301
|
+
// full 25-run budget read /proc for every process on the host 25 times.
|
|
1302
|
+
let passSnapshot: ProcSnapshot | undefined;
|
|
1303
|
+
const sweepSnapshot = (): ProcSnapshot => (passSnapshot ??= buildProcSnapshot());
|
|
1304
|
+
await mapConcurrent(terminalRunIds, 2, async (runId): Promise<void> => {
|
|
1305
|
+
try {
|
|
1306
|
+
const loaded = await loadRunManifestByIdAsync(cwd, runId); // best-effort; re-read under the lock below
|
|
1307
|
+
if (!loaded) return;
|
|
1308
|
+
await withRunLock(loaded.manifest, async () => {
|
|
1309
|
+
const fresh = await loadRunManifestByIdAsync(cwd, runId); // consistent read inside the lock
|
|
1310
|
+
if (!fresh) return;
|
|
1311
|
+
// Idempotency: a concurrent pass (or the run's own completion path)
|
|
1312
|
+
// already stamped this run clean ⇒ nothing to do, and no /proc scan.
|
|
1313
|
+
// 11b.4 (HIGH-2): a run stamped `descendantSweepPending` is NOT done
|
|
1314
|
+
// — it is due again once the retry interval has elapsed, which is
|
|
1315
|
+
// what the cache selection just said; re-checked here because the
|
|
1316
|
+
// fresh read is authoritative and another pass may have just run it.
|
|
1317
|
+
if (!isTerminalSweepEligible(fresh.manifest, now)) return;
|
|
1318
|
+
// The run may have flipped back to running/blocked (resume) between
|
|
1319
|
+
// the cache list and this lock — that run belongs to the reconcile
|
|
1320
|
+
// lane, and its descendants are legitimate again.
|
|
1321
|
+
if (!isTerminalRunStatus(fresh.manifest.status)) return;
|
|
1322
|
+
// 11b.3: re-check the grace window on the FRESH manifest — a run that
|
|
1323
|
+
// resumed and finished again since the cache snapshot is young again,
|
|
1324
|
+
// and its panes may have just been re-attached.
|
|
1325
|
+
if (now - terminalSinceMs(fresh.manifest) < TERMINAL_SWEEP_MIN_AGE_MS) return;
|
|
1326
|
+
sweepTerminalRunDescendants(fresh.manifest, fresh.tasks, { snapshot: sweepSnapshot() });
|
|
1327
|
+
});
|
|
1328
|
+
// Wave 11a review MAJOR #3 (same owner as the reconcile lane): the sync
|
|
1329
|
+
// sweep arms a group SIGKILL escalation it cannot await.
|
|
1330
|
+
await drainReapEscalations();
|
|
1331
|
+
} catch (error) {
|
|
1332
|
+
logInternalError("crash-recovery.terminal-descendant-sweep", error, `runId=${runId}`, "warn");
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1133
1335
|
// US-002 (2026-09-22): structured stale-lock sweep. Runs after reconciliation
|
|
1134
1336
|
// so locks whose holder died (kill -9 / crash) do not linger. The sweep is
|
|
1135
1337
|
// strictly safer than acquire-time steal (stale AND holder-dead only), so a
|
|
@@ -52,10 +52,30 @@ export interface RpcSpawnHandle {
|
|
|
52
52
|
/** Spawn function: receives the pi argv (after `--mode rpc` etc.), returns a handle. */
|
|
53
53
|
export type RpcSpawnFn = (argv: string[]) => RpcSpawnHandle;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Spawn options for the frame client's child (wave 11a, F4). Exported as a pure
|
|
57
|
+
* function so the platform-dependent group-leadership flag is unit-testable
|
|
58
|
+
* without spawning a real pi binary. POSIX: `detached` makes the child its own
|
|
59
|
+
* process-group leader, so a group-targeted teardown can reach its descendants.
|
|
60
|
+
* `detached` does NOT detach the stdio pipes — frames still round-trip through
|
|
61
|
+
* the same fd 0/1/2. Windows has no process groups (single-process kill
|
|
62
|
+
* fallback), so it never detaches.
|
|
63
|
+
*/
|
|
64
|
+
export function frameClientSpawnOptions(platform: NodeJS.Platform): {
|
|
65
|
+
stdio: ["pipe", "pipe", "pipe"];
|
|
66
|
+
detached: boolean;
|
|
67
|
+
windowsHide: boolean;
|
|
68
|
+
} {
|
|
69
|
+
return { stdio: ["pipe", "pipe", "pipe"], detached: platform !== "win32", windowsHide: true };
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/** Default spawner: host pi binary (PI_TEAMS_PI_BIN aware) with piped stdio. */
|
|
56
73
|
function defaultSpawnFn(argv: string[]): RpcSpawnHandle {
|
|
57
74
|
const { command, args } = getPiSpawnCommand(argv);
|
|
58
|
-
|
|
75
|
+
// Wave 11a (F4): mirror the headless spawn branch (child-pi-spawn.ts) — see
|
|
76
|
+
// frameClientSpawnOptions above for why `detached` is on and why it does not
|
|
77
|
+
// break piped stdio.
|
|
78
|
+
const child = spawn(command, args, frameClientSpawnOptions(process.platform));
|
|
59
79
|
return {
|
|
60
80
|
stdin: child.stdin as Writable,
|
|
61
81
|
stdout: child.stdout as Readable,
|