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.
@@ -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
- const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] });
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,