pi-crew 0.11.2 → 0.11.4

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +44 -1
  2. package/dist/index.mjs +491 -493
  3. package/package.json +5 -2
  4. package/skills/real-test-pi-crew/SKILL.md +19 -11
  5. package/src/extension/async-notifier.ts +32 -8
  6. package/src/extension/crew-input-router.ts +9 -2
  7. package/src/extension/plan-orchestrate.ts +19 -3
  8. package/src/extension/registration/commands/shared.ts +11 -10
  9. package/src/extension/registration/foreground-run-controller.ts +2 -2
  10. package/src/extension/registration/lazy-configurers.ts +4 -1
  11. package/src/extension/registration/lifecycle-handlers.ts +16 -10
  12. package/src/extension/registration/lifecycle.ts +2 -0
  13. package/src/extension/registration/observability.ts +27 -18
  14. package/src/extension/registration/team-tool.ts +9 -2
  15. package/src/extension/registration/wire-cross-extension.ts +2 -0
  16. package/src/extension/team-manager-command.ts +3 -2
  17. package/src/extension/team-tool/goal-wrap.ts +7 -0
  18. package/src/extension/team-tool/goal.ts +12 -0
  19. package/src/extension/team-tool/parallel-dispatch.ts +16 -1
  20. package/src/extension/team-tool/run-intent.ts +1 -1
  21. package/src/extension/team-tool/run.ts +1 -1
  22. package/src/runtime/async-runner.ts +36 -0
  23. package/src/runtime/broker/crew-broker.ts +61 -62
  24. package/src/runtime/broker/delegate/delegate-event.ts +12 -8
  25. package/src/runtime/broker/protocol/connection-state.ts +10 -0
  26. package/src/runtime/broker/protocol/lock-busy.ts +76 -0
  27. package/src/runtime/delegate-spawn.ts +16 -2
  28. package/src/runtime/deterministic-ast.ts +14 -2
  29. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +78 -58
  30. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +14 -2
  31. package/src/runtime/goal-workflow/goal-loop-runner.ts +32 -0
  32. package/src/runtime/group-join.ts +11 -3
  33. package/src/runtime/live-session/live-session-runtime.ts +1 -1
  34. package/src/runtime/model/runtime-warmup.ts +2 -0
  35. package/src/runtime/output/output-validator.ts +10 -5
  36. package/src/runtime/plan-approval.ts +14 -1
  37. package/src/runtime/recovery/crash-recovery.ts +75 -52
  38. package/src/runtime/subagent-manager.ts +43 -13
  39. package/src/runtime/task-runner/child-executor.ts +17 -6
  40. package/src/runtime/task-runner/post-execution.ts +44 -14
  41. package/src/runtime/task-runner/prompt-builder.ts +8 -0
  42. package/src/runtime/task-runner/retrieval-orchestrator.ts +36 -14
  43. package/src/runtime/task-runner.ts +7 -0
  44. package/src/runtime/team-runner.ts +4 -1
  45. package/src/runtime/verification/verification-worktree.ts +3 -0
  46. package/src/state/coordination/mailbox.ts +7 -2
  47. package/src/state/stores/state-store.ts +8 -1
  48. package/src/ui/dashboard-panes/agents-pane.ts +11 -8
  49. package/src/ui/inline-panel/index.ts +1 -1
  50. package/src/ui/render-diff.ts +19 -6
  51. package/src/ui/run-dashboard.ts +11 -1
  52. package/src/ui/run-snapshot-cache.ts +9 -3
  53. package/src/ui/widget/task-list.ts +8 -5
  54. package/src/ui/widget/widget-model.ts +3 -1
  55. package/tsconfig.json +2 -0
  56. package/src/ui/card-colors.ts +0 -168
  57. package/src/utils/completion-dedupe.ts +0 -63
  58. package/src/utils/resolve-shell.ts +0 -36
@@ -906,67 +906,87 @@ function describeSchemaShape(schema: unknown, depth: number): string {
906
906
  * reject is checked first because a review can mention both "correctly" (describing existing code)
907
907
  * AND "critical bug" (the verdict) — the verdict signal must win.
908
908
  */
909
+ // Precompiled review-outcome signals (module scope — compiled once, not per call).
910
+ // RR-021 WI-1.1: these were previously string literals like "\baccept\b" compiled
911
+ // per call — and "\b" in a TS string literal is a BACKSPACE character (U+0008),
912
+ // so those two signals never matched anything. Regex literals here give real word
913
+ // boundaries. No /g flags → no shared lastIndex state across .test() calls.
914
+ const REJECT_SIGNAL_REGEXES: readonly RegExp[] = [
915
+ /\breject\b/,
916
+ /fundamentally/,
917
+ /completely broken/,
918
+ /totally broken/,
919
+ /critical bug/,
920
+ /critical issue/,
921
+ /critical flaw/,
922
+ /security vulnerability/,
923
+ /does not work/,
924
+ /doesn't work/,
925
+ /will not work/,
926
+ /fails to/,
927
+ /unacceptable/,
928
+ /must not be merged/,
929
+ /do not merge/,
930
+ /wrong approach/,
931
+ /logically incorrect/,
932
+ /incorrectly implements/,
933
+ /returns the opposite/,
934
+ /subtraction instead of addition/,
935
+ /opposite of its intended/,
936
+ ];
937
+ const ACCEPT_SIGNAL_REGEXES: readonly RegExp[] = [
938
+ /\baccept\b/,
939
+ /looks good/,
940
+ /well done/,
941
+ /no issues/,
942
+ /no real issues/,
943
+ /no problems/,
944
+ /no concerns/,
945
+ /nothing to change/,
946
+ /ready to merge/,
947
+ /lgtm/,
948
+ /ship it/,
949
+ /correctly implements/,
950
+ /correctly returns/,
951
+ /works as expected/,
952
+ /works correctly/,
953
+ /no bugs/,
954
+ /no defects/,
955
+ /meets all requirements/,
956
+ /all requirements met/,
957
+ /passes all/,
958
+ /is correct/,
959
+ /are correct/,
960
+ /no changes needed/,
961
+ /no changes required/,
962
+ /no further changes/,
963
+ /nothing more to/,
964
+ /complete and correct/,
965
+ /sound implementation/,
966
+ ];
967
+ // RR-021 WI-1.1 negation guard: phrases that NEGATE the bare "accept" signal.
968
+ // "I cannot accept this" is a complaint, not approval — the accept hit must be
969
+ // discarded so the outcome falls through to changes_requested (or reject if a
970
+ // reject signal is also present).
971
+ const NEGATED_ACCEPT_REGEXES: readonly RegExp[] = [
972
+ /\bcannot\s+accept\b/,
973
+ /\bcan'?t\s+accept\b/,
974
+ /\bdon'?t\s+accept\b/,
975
+ /\bwon'?t\s+accept\b/,
976
+ /\bdo\s+not\s+accept\b/,
977
+ /\bwill\s+not\s+accept\b/,
978
+ /\bunable\s+to\s+accept\b/,
979
+ /\bnot\s+accept(?:ed|ing|able)?\b/,
980
+ ];
981
+
909
982
  export function classifyReviewOutcome(prose: string): "accept" | "reject" | "changes_requested" {
910
983
  const text = prose.toLowerCase();
911
- // Strong negative signals → reject. These indicate fundamental/critical problems.
912
- const rejectSignals = [
913
- "\breject\b",
914
- "fundamentally",
915
- "completely broken",
916
- "totally broken",
917
- "critical bug",
918
- "critical issue",
919
- "critical flaw",
920
- "security vulnerability",
921
- "does not work",
922
- "doesn't work",
923
- "will not work",
924
- "fails to",
925
- "unacceptable",
926
- "must not be merged",
927
- "do not merge",
928
- "wrong approach",
929
- "logically incorrect",
930
- "incorrectly implements",
931
- "returns the opposite",
932
- "subtraction instead of addition",
933
- "opposite of its intended",
934
- ];
935
- // Acceptance signals → accept. These indicate explicit approval with no real issues.
936
- const acceptSignals = [
937
- "\baccept\b",
938
- "looks good",
939
- "well done",
940
- "no issues",
941
- "no real issues",
942
- "no problems",
943
- "no concerns",
944
- "nothing to change",
945
- "ready to merge",
946
- "lgtm",
947
- "ship it",
948
- "correctly implements",
949
- "correctly returns",
950
- "works as expected",
951
- "works correctly",
952
- "no bugs",
953
- "no defects",
954
- "meets all requirements",
955
- "all requirements met",
956
- "passes all",
957
- "is correct",
958
- "are correct",
959
- "no changes needed",
960
- "no changes required",
961
- "no further changes",
962
- "nothing more to",
963
- "complete and correct",
964
- "sound implementation",
965
- ];
966
- const hasReject = rejectSignals.some((sig) => new RegExp(sig).test(text));
967
- const hasAccept = acceptSignals.some((sig) => new RegExp(sig).test(text));
984
+ // Reject-first precedence: a review can mention both "correctly" (describing
985
+ // existing code) AND "critical bug" (the verdict) — the verdict signal must win.
986
+ const hasReject = REJECT_SIGNAL_REGEXES.some((re) => re.test(text));
968
987
  if (hasReject) return "reject";
969
- if (hasAccept) return "accept";
988
+ const hasAccept = ACCEPT_SIGNAL_REGEXES.some((re) => re.test(text));
989
+ if (hasAccept && !NEGATED_ACCEPT_REGEXES.some((re) => re.test(text))) return "accept";
970
990
  return "changes_requested";
971
991
  }
972
992
 
@@ -18,8 +18,8 @@
18
18
  */
19
19
 
20
20
  import { readFileSync } from "node:fs";
21
+ import { createRequire } from "node:module";
21
22
  import { join } from "node:path";
22
- import { transformSync } from "esbuild";
23
23
  import { getCrewEnv } from "../../config/env-vars.ts";
24
24
  import { appendEvent } from "../../state/event-log/event-log.ts";
25
25
  import { writeArtifact } from "../../state/stores/artifact-store.ts";
@@ -33,6 +33,18 @@ import { assertDeterministicScript, isDeterminismCheckEnabled } from "../determi
33
33
  import { DwfStore } from "../dwf-state-store.ts";
34
34
  import { getWorkflowFinalResult, getWorkflowPhaseState, makeWorkflowCtx } from "./dynamic-workflow-context.ts";
35
35
 
36
+ // RR-021 WI-2.1: esbuild is a heavy native dep (~35ms+ cold load) but is only
37
+ // needed when a .dwf.ts script actually executes. Lazy-load via a synchronous
38
+ // createRequire shim on first use (model: ui/syntax-highlight.ts) — call sites
39
+ // stay sync. esbuild remains external in the bundle and resolves through here.
40
+ const nodeRequire = createRequire(import.meta.url);
41
+ type EsbuildModule = typeof import("esbuild");
42
+ let cachedEsbuild: EsbuildModule | undefined;
43
+ function esbuild(): EsbuildModule {
44
+ if (!cachedEsbuild) cachedEsbuild = nodeRequire("esbuild") as EsbuildModule;
45
+ return cachedEsbuild;
46
+ }
47
+
36
48
  export interface RunDynamicWorkflowInput {
37
49
  manifest: TeamRunManifest;
38
50
  workflow: DynamicWorkflowConfig;
@@ -119,7 +131,7 @@ async function loadWorkflowModule(scriptPath: string): Promise<DynamicWorkflowSc
119
131
  // DISC-1: acorn can only parse JavaScript, not TypeScript. Real .dwf.ts files
120
132
  // contain type annotations/imports that cause a silent parse error, so the
121
133
  // determinism check never runs. Transpile to JS via esbuild first.
122
- const js = transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
134
+ const js = esbuild().transformSync(scriptSource, { loader: "ts", format: "esm" }).code;
123
135
  assertDeterministicScript(js);
124
136
  }
125
137
  // jiti is the same loader async-runner.ts uses (resolveTypeScriptLoader). We require it
@@ -29,6 +29,7 @@ import type { TeamConfig } from "../../teams/team-config.ts";
29
29
  import { logInternalError } from "../../utils/internal-error.ts";
30
30
  import type { WorkflowConfig } from "../../workflows/workflow-config.ts";
31
31
  import { resolveCrewRuntime } from "../model/runtime-resolver.ts";
32
+ import { registryFromModelContext } from "../model/session-model.ts";
32
33
  import { executeTeamRun } from "../team-runner.ts";
33
34
  import { compareSnapshot, snapshotManifests } from "../verification/verification-integrity.ts";
34
35
  import { acquireWorkspaceLock, type WorkspaceLockHandle } from "../workspace-lock.ts";
@@ -65,6 +66,29 @@ export interface RunGoalLoopResult {
65
66
  goalState: GoalLoopState;
66
67
  }
67
68
 
69
+ /**
70
+ * Finding #1 (battery 2026-09-29): re-hydrate the model routing inputs the
71
+ * OUTER goal-loop manifest persisted at dispatch time (captureRunModelContext
72
+ * in goal.ts / goal-wrap.ts). The detached goal-loop runs in the background
73
+ * runner with no ExtensionContext — without this, every turn's executeTeamRun
74
+ * resolves the DEFAULT chain (live: minimax/MiniMax-M3 402 while the parent
75
+ * session ran zai/glm-5.3). Mirrors background-runner's restoredModelRouting().
76
+ */
77
+ function restoredTurnModelRouting(manifest: TeamRunManifest): {
78
+ modelOverride?: string;
79
+ parentModel?: string;
80
+ modelRegistry?: { getAvailable: () => unknown[] };
81
+ } {
82
+ const context = manifest.modelContext;
83
+ if (!context) return {};
84
+ const modelRegistry = registryFromModelContext(context);
85
+ return {
86
+ ...(context.override ? { modelOverride: context.override } : {}),
87
+ ...(context.parentModel ? { parentModel: context.parentModel } : {}),
88
+ ...(modelRegistry ? { modelRegistry } : {}),
89
+ };
90
+ }
91
+
68
92
  /**
69
93
  * The placeholder evaluator for P0: always returns {achieved:false}.
70
94
  * Kept for unit tests of the loop's max_turns exit path. The production loop
@@ -630,6 +654,10 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
630
654
  workspaceMode: "single",
631
655
  ownerSessionId: goal.ownerSessionId,
632
656
  runKind: "team-run", // §0a v2 note: turns are normal team-runs; the OUTER loop is goal-loop
657
+ // Finding #1: turns execute in the detached process — carry the outer
658
+ // manifest's model routing snapshot onto each turn manifest too, so
659
+ // status/resume paths see the same routing the turn was dispatched with.
660
+ ...(manifest.modelContext ? { modelContext: manifest.modelContext } : {}),
633
661
  });
634
662
  goal =
635
663
  store.patch(
@@ -689,6 +717,10 @@ export async function runGoalLoop(input: RunGoalLoopInput): Promise<RunGoalLoopR
689
717
  limits: turnExecutedConfig.limits,
690
718
  runtime: turnRuntime,
691
719
  runtimeConfig: turnExecutedConfig.runtime,
720
+ // Finding #1: restore the dispatch-time model routing (see
721
+ // restoredTurnModelRouting) — executeTeamRun in THIS process has no
722
+ // ExtensionContext to inherit the session model from.
723
+ ...restoredTurnModelRouting(manifest),
692
724
  reliability: turnExecutedConfig.reliability,
693
725
  workspaceId: goal.ownerSessionId ?? goal.cwd,
694
726
  signal,
@@ -59,9 +59,17 @@ export function deliverGroupJoin(input: {
59
59
  }): CrewGroupJoinDelivery | undefined {
60
60
  if (!shouldGroupJoin(input.mode, input.batch)) return undefined;
61
61
  const taskIds = input.batch.map((task) => task.id);
62
- const latest = taskIds
63
- .map((id) => input.allTasks.find((task) => task.id === id))
64
- .filter((task): task is TeamTaskState => Boolean(task));
62
+ // RR-021 WI-4.3d: first-wins Map — the old taskIds.map(id => allTasks.find())
63
+ // was O(batch×allTasks). NOT a raw input.batch pass-through: callers may
64
+ // hold stale batch snapshots, and the only audited production caller
65
+ // (team-runner closeout) passes fresh references — but the Map keeps the
66
+ // exact `.find` first-match semantics for every caller (incl. tests) while
67
+ // staying O(n+m). Batch order is preserved (map follows taskIds).
68
+ const taskById = new Map<string, TeamTaskState>();
69
+ for (const task of input.allTasks) {
70
+ if (!taskById.has(task.id)) taskById.set(task.id, task);
71
+ }
72
+ const latest = taskIds.map((id) => taskById.get(id)).filter((task): task is TeamTaskState => Boolean(task));
65
73
  const completed = statusList(latest, "completed");
66
74
  const failed = statusList(latest, "failed");
67
75
  const skipped = statusList(latest, "skipped");
@@ -96,7 +96,7 @@ let liveSessionModulePromise: Promise<LiveSessionModule> | undefined;
96
96
  let experimentalWarned = false;
97
97
  function loadLiveSessionModule(): Promise<LiveSessionModule> {
98
98
  if (!liveSessionModulePromise) {
99
- liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>;
99
+ liveSessionModulePromise = import("@earendil-works/pi-coding-agent") as unknown as Promise<LiveSessionModule>; // LAZY: defer pi-coding-agent live-session module until first use
100
100
  }
101
101
  return liveSessionModulePromise;
102
102
  }
@@ -124,6 +124,7 @@ export function startRuntimeWarmup(): void {
124
124
  for (const spec of HOT_MODULE_SPECIFIERS) {
125
125
  imports.push(
126
126
  import(new URL(spec, import.meta.url).href).catch(() => {
127
+ // LAZY: best-effort warmup import, failures tolerated
127
128
  // swallow — never block registration on a warmup failure
128
129
  }),
129
130
  );
@@ -131,6 +132,7 @@ export function startRuntimeWarmup(): void {
131
132
  for (const dep of HOT_PEER_DEPS) {
132
133
  imports.push(
133
134
  import(dep).catch(() => {
135
+ // LAZY: best-effort warmup import, failures tolerated
134
136
  // peer dep may be absent (optional dep) — swallow
135
137
  }),
136
138
  );
@@ -123,10 +123,15 @@ export function validateWorkerOutput(role: string, output: string): OutputValida
123
123
  *
124
124
  * A trimmed non-empty line counts as log noise ONLY if it matches one of:
125
125
  * 1. a bracket-tag extension log line: `[oc-go] ...`, `[pi-qwen-mm] ...`,
126
- * `[pi-qwen-mm] [core] [stderr] ...` — the leading tag is a lowercase
127
- * identifier (deliberately excludes capitalized bracketed prose like
128
- * `[Note] ...`), optionally followed by known sub-tags
129
- * (core/mcp/stderr/stdout/warn/info/error/debug);
126
+ * `[pi-qwen-mm] [core] [stderr] ...`, and — Finding #2 (2026-09-29
127
+ * battery) — colon-bearing extension tags like
128
+ * `[pi-crew:crash-recovery.reconcileStaleRuns] ...` or
129
+ * `[pi-crew:crew-vibes.publish-quota-status] ...`. The leading tag
130
+ * starts with a lowercase char (deliberately excludes capitalized
131
+ * bracketed prose like `[Note] ...` / `[Note: ...]`); the rest of the tag
132
+ * may carry `:`, `-`, `_`, `.`, digits, and inner capitals (extension
133
+ * subsystem names are camelCase, e.g. `reconcileStaleRuns`), optionally
134
+ * followed by known sub-tags (core/mcp/stderr/stdout/warn/info/error/debug);
130
135
  * 2. a Python `warnings.warn(` line (deprecation-warning continuation);
131
136
  * 3. a timestamped logging line: `2026-08-15 21:49:02,986 WARNING ...`.
132
137
  *
@@ -137,7 +142,7 @@ export function validateWorkerOutput(role: string, output: string): OutputValida
137
142
  * (post-execution.ts) applies the two-gate rule (authoritative output
138
143
  * sources empty AND artifact log-noise-only) before failing a task.
139
144
  */
140
- const BRACKET_TAG_LOG_LINE = /^\[[a-z0-9][a-z0-9_.-]*\](?:\s*\[(?:core|mcp|stderr|stdout|warn|info|error|debug)\])*(?:\s.*)?$/;
145
+ const BRACKET_TAG_LOG_LINE = /^\[[a-z0-9][a-zA-Z0-9_.:\-]*\](?:\s*\[(?:core|mcp|stderr|stdout|warn|info|error|debug)\])*(?:\s.*)?$/;
141
146
  const PYTHON_WARNING_LINE = /(?:^|\s)warnings\.warn\(/;
142
147
  const TIMESTAMPED_LOG_LINE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d{1,6})?(?:Z|[+-]\d{2}:?\d{2})?\s+[A-Za-z]+\b/;
143
148
 
@@ -70,7 +70,20 @@ export async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tas
70
70
  const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
71
71
  // ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
72
72
  // completed read-only (planning) task as the plan reference.
73
- const planTask = assessTask ?? [...tasks].reverse().find((t) => t.status === "completed" && !isMutatingTask(t));
73
+ // RR-021 WI-4.3e: allocation-free reverse scan — the old
74
+ // [...tasks].reverse().find() copied the whole array per call.
75
+ // (findLast needs an ES2023 lib bump, which breaks tsgo's RequestInfo
76
+ // ambient resolution — see WI-4.3 evidence note.)
77
+ let planTask = assessTask;
78
+ if (!planTask) {
79
+ for (let i = tasks.length - 1; i >= 0; i--) {
80
+ const t = tasks[i];
81
+ if (t?.status === "completed" && !isMutatingTask(t)) {
82
+ planTask = t;
83
+ break;
84
+ }
85
+ }
86
+ }
74
87
  const now = new Date().toISOString();
75
88
 
76
89
  // T2/R4 (ADR-4 §6 producer 3 + §8 dual-write): make sure a PlanRecord
@@ -4,10 +4,16 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { DEFAULT_PATHS } from "../../config/defaults.ts";
5
5
  import { appendHookEvent, executeHook } from "../../hooks/registry.ts";
6
6
  import type { MetricRegistry } from "../../observability/metric-registry.ts";
7
- import { discoverRunLockFiles, sweepStaleLocks, withRunLockSync } from "../../state/coordination/locks.ts";
7
+ import { discoverRunLockFiles, sweepStaleLocks, withRunLock, withRunLockSync } from "../../state/coordination/locks.ts";
8
8
  import { appendEvent, scanSequence } from "../../state/event-log/event-log.ts";
9
9
  import { readActiveRunRegistry, unregisterActiveRun } from "../../state/stores/active-run-registry.ts";
10
- import { loadRunManifestById, saveRunManifest, saveRunTasks, updateRunStatus } from "../../state/stores/state-store.ts";
10
+ import {
11
+ loadRunManifestById,
12
+ loadRunManifestByIdAsync,
13
+ saveRunManifest,
14
+ saveRunTasks,
15
+ updateRunStatus,
16
+ } from "../../state/stores/state-store.ts";
11
17
  import type { TeamTaskState } from "../../state/types.ts";
12
18
  import { logInternalError } from "../../utils/internal-error.ts";
13
19
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
@@ -18,6 +24,7 @@ import { isWorkerHeartbeatStale } from "../heartbeat/worker-heartbeat.ts";
18
24
  import { terminateLiveAgentsForRun } from "../live-session/live-agent-manager.ts";
19
25
  import type { ManifestCache } from "../manifest-cache.ts";
20
26
  import { checkProcessLiveness } from "../process-status.ts";
27
+ import { mapConcurrent } from "../scheduling/parallel-utils.ts";
21
28
  import { isIntentionalWait, isPlanApprovalPendingEffective, type ReconcileResult, reconcileStaleRun } from "../stale-reconciler.ts";
22
29
 
23
30
  export interface RecoveryPlan {
@@ -690,13 +697,12 @@ export function purgeStaleActiveRunIndex(
690
697
  return { purged, kept };
691
698
  }
692
699
 
693
- export function reconcileAllStaleRuns(
700
+ export async function reconcileAllStaleRuns(
694
701
  cwd: string,
695
702
  manifestCache: ManifestCache,
696
703
  now = Date.now(),
697
704
  currentSessionId?: string,
698
- ): ReconcileResult[] {
699
- const results: ReconcileResult[] = [];
705
+ ): Promise<ReconcileResult[]> {
700
706
  // Capture runIds to reconcile BEFORE acquiring locks — avoids TOCTOU between cache iteration and lock acquisition.
701
707
  const runIds = manifestCache
702
708
  .list(50)
@@ -708,56 +714,73 @@ export function reconcileAllStaleRuns(
708
714
  return true;
709
715
  })
710
716
  .map((m) => m.runId);
711
- for (const runId of runIds) {
712
- const cached = manifestCache.get(runId);
713
- if (!cached) continue;
714
- const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
715
- if (!loaded) continue;
716
- // Use lock to prevent race with cancel/status handlers modifying the same run
717
- withRunLockSync(loaded.manifest, () => {
718
- // Re-read inside lock to get freshest data
719
- const fresh = loadRunManifestById(cwd, runId); // NOTE: inside withRunLockSync - consistent read
720
- if (!fresh || (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked")) return;
721
- // Belt-and-suspenders: reconcileStaleRun itself guards this, but the run
722
- // may have flipped to blocked+plan-approval between cache-list and lock
723
- // acquisition — re-check the freshest manifest under the lock.
724
- if (isPlanApprovalPendingEffective(fresh.manifest)) {
725
- results.push({
726
- runId,
727
- verdict: "blocked_awaiting_approval",
728
- repaired: false,
729
- detail: "Plan approval is pending; stale reconciliation skipped",
730
- });
731
- return;
732
- }
733
- const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
734
- if (result.repaired || result.verdict === "result_exists") {
735
- if (result.repairedTasks) {
736
- saveRunTasks(fresh.manifest, result.repairedTasks);
737
- for (const task of result.repairedTasks) {
738
- try {
739
- upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
740
- } catch {
741
- /* non-critical */
717
+ // RR-021 WI-1.5 (audit C3): serial reconcile → mapConcurrent bound 4.
718
+ // Each runId acquires its OWN run lock file (lockPath() is per-run), so the
719
+ // acquisitions are independent; bound 4 caps fs/CPU contention. Results keep
720
+ // input (snapshot) order — mapConcurrent indexes by item — and per-run
721
+ // semantics (re-read inside lock, plan-approval re-check) are unchanged.
722
+ const perRun = await mapConcurrent(runIds, 4, async (runId): Promise<ReconcileResult[]> => {
723
+ // RR-021 review remediation: per-run error isolation — one corrupt run
724
+ // must not reject the whole sweep (serial baseline threw too, but the
725
+ // SPEC's "independent per-run" intent requires continue-on-error).
726
+ try {
727
+ const cached = manifestCache.get(runId);
728
+ if (!cached) return [];
729
+ const loaded = await loadRunManifestByIdAsync(cwd, runId); // NOTE: best-effort only; concurrent writes may cause inconsistency
730
+ if (!loaded) return [];
731
+ const out: ReconcileResult[] = [];
732
+ // Use lock to prevent race with cancel/status handlers modifying the same run.
733
+ // Same v0.9.26 lock family as the old sync acquisition — interop-safe.
734
+ await withRunLock(loaded.manifest, async () => {
735
+ // Re-read inside lock to get freshest data
736
+ const fresh = await loadRunManifestByIdAsync(cwd, runId); // NOTE: inside withRunLock - consistent read
737
+ if (!fresh || (fresh.manifest.status !== "running" && fresh.manifest.status !== "blocked")) return;
738
+ // Belt-and-suspenders: reconcileStaleRun itself guards this, but the run
739
+ // may have flipped to blocked+plan-approval between cache-list and lock
740
+ // acquisition — re-check the freshest manifest under the lock.
741
+ if (isPlanApprovalPendingEffective(fresh.manifest)) {
742
+ out.push({
743
+ runId,
744
+ verdict: "blocked_awaiting_approval",
745
+ repaired: false,
746
+ detail: "Plan approval is pending; stale reconciliation skipped",
747
+ });
748
+ return;
749
+ }
750
+ const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
751
+ if (result.repaired || result.verdict === "result_exists") {
752
+ if (result.repairedTasks) {
753
+ saveRunTasks(fresh.manifest, result.repairedTasks);
754
+ for (const task of result.repairedTasks) {
755
+ try {
756
+ upsertCrewAgent(fresh.manifest, recordFromTask(fresh.manifest, task, "scaffold"));
757
+ } catch {
758
+ /* non-critical */
759
+ }
742
760
  }
743
761
  }
762
+ updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
763
+ void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) =>
764
+ logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`, "warn"),
765
+ );
766
+ appendEvent(fresh.manifest.eventsPath, {
767
+ type: "crew.run.reconciled_stale",
768
+ runId,
769
+ message: result.detail,
770
+ data: { verdict: result.verdict },
771
+ });
744
772
  }
745
- updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
746
- void terminateLiveAgentsForRun(fresh.manifest.runId, "failed", appendEvent, fresh.manifest.eventsPath).catch((error) =>
747
- logInternalError("crash-recovery.reconcile.terminate", error, `runId=${fresh.manifest.runId}`, "warn"),
748
- );
749
- appendEvent(fresh.manifest.eventsPath, {
750
- type: "crew.run.reconciled_stale",
751
- runId,
752
- message: result.detail,
753
- data: { verdict: result.verdict },
754
- });
755
- }
756
- if (result.verdict !== "healthy") {
757
- results.push(result);
758
- }
759
- });
760
- }
773
+ if (result.verdict !== "healthy") {
774
+ out.push(result);
775
+ }
776
+ });
777
+ return out;
778
+ } catch (error) {
779
+ logInternalError("crash-recovery.reconcileStaleRuns", error, `runId=${runId}`, "warn");
780
+ return [];
781
+ }
782
+ });
783
+ const results = perRun.flat();
761
784
  // US-002 (2026-09-22): structured stale-lock sweep. Runs after reconciliation
762
785
  // so locks whose holder died (kill -9 / crash) do not linger. The sweep is
763
786
  // strictly safer than acquire-time steal (stale AND holder-dead only), so a
@@ -342,16 +342,43 @@ export class SubagentManager {
342
342
  }
343
343
  }
344
344
 
345
- async waitForRecord(id: string): Promise<SubagentRecord | undefined> {
345
+ async waitForRecord(id: string, timeoutMs = 300_000): Promise<SubagentRecord | undefined> {
346
+ // RR-021 WI-4.3i: bounded wait. A record with no promise whose status
347
+ // stays running/blocked used to spin forever (100ms sleep loop). On
348
+ // deadline expiry we return the CURRENT record — never undefined, which
349
+ // would be ambiguous with 'no such record'. Callers can inspect
350
+ // record.status to see the wait timed out on a still-active record.
351
+ const deadline = Date.now() + timeoutMs;
346
352
  while (true) {
347
353
  const record = this.records.get(id);
348
354
  if (!record) return undefined;
349
355
  if (record.status !== "running" && record.status !== "queued") return record;
350
- if (record.promise)
351
- await record.promise.catch((error) => {
352
- logInternalError("subagent-manager.waitForRecord", error, `id=${id}`);
353
- });
354
- else await new Promise((resolve) => setTimeout(resolve, 100));
356
+ if (Date.now() > deadline) return record;
357
+ if (record.promise) {
358
+ // Race the run promise against the remaining deadline — a wedged
359
+ // promise must not block the deadline check.
360
+ const remaining = deadline - Date.now();
361
+ let timer: NodeJS.Timeout | undefined;
362
+ try {
363
+ await Promise.race([
364
+ record.promise.catch((error) => {
365
+ logInternalError("subagent-manager.waitForRecord", error, `id=${id}`);
366
+ }),
367
+ // RR-021 review remediation: this timer MUST stay ref'd. unref() let the
368
+ // event loop drain before the deadline fired (node:test then cancels the
369
+ // awaiting test — "Promise resolution is still pending but the event loop
370
+ // has already resolved"). Always clearTimeout'd in the finally below, so
371
+ // ref'ing cannot leak (knowledge.md OwnedProcess timer guidance).
372
+ new Promise<void>((resolve) => {
373
+ timer = setTimeout(resolve, remaining);
374
+ }),
375
+ ]);
376
+ } finally {
377
+ if (timer !== undefined) clearTimeout(timer);
378
+ }
379
+ } else {
380
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, Math.max(1, deadline - Date.now()))));
381
+ }
355
382
  }
356
383
  }
357
384
 
@@ -477,19 +504,22 @@ export class SubagentManager {
477
504
  }
478
505
 
479
506
  private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
480
- // Safety: max 30 minutes (1800 polls at 1s interval) to prevent infinite
481
- // polling if manifest file is deleted or run state becomes unrecoverable.
482
- const MAX_POLL_COUNT = 1800;
483
- let pollCount = 0;
507
+ // Safety: max 30 minutes of WALL-CLOCK polling to prevent infinite
508
+ // polling if the manifest file is deleted or run state becomes unrecoverable.
509
+ // (RR-021 WI-4.3h: was MAX_POLL_COUNT=1800, which silently assumed a 1s
510
+ // pollIntervalMs — but the interval is configurable, so a 5s interval
511
+ // polled for 2.5h while a 200ms interval timed out after 6 minutes.)
512
+ const POLL_DEADLINE_MS = 30 * 60 * 1000;
513
+ const pollDeadline = Date.now() + POLL_DEADLINE_MS;
484
514
  while (record.runId && (record.status === "running" || record.status === "blocked")) {
485
- if (++pollCount > MAX_POLL_COUNT) {
515
+ if (Date.now() > pollDeadline) {
486
516
  logInternalError(
487
517
  "subagent-manager.poll-timeout",
488
- new Error(`pollRunToTerminal exceeded ${MAX_POLL_COUNT} polls for runId=${record.runId}`),
518
+ new Error(`pollRunToTerminal exceeded ${POLL_DEADLINE_MS}ms wall-clock for runId=${record.runId}`),
489
519
  `id=${record.id}`,
490
520
  );
491
521
  record.status = "error";
492
- record.error = `Poll timeout: run did not reach terminal state after ${MAX_POLL_COUNT} seconds`;
522
+ record.error = `Poll timeout: run did not reach terminal state after ${POLL_DEADLINE_MS}ms (wall-clock)`;
493
523
  record.completedAt = Date.now();
494
524
  savePersistedSubagentRecord(cwd, record);
495
525
  return;
@@ -68,7 +68,7 @@ import { DEFAULT_RETRY_POLICY } from "../recovery/retry-executor.ts";
68
68
  import { runWorker } from "../run-worker.ts";
69
69
  import { parseSessionUsage } from "../session-usage.ts";
70
70
  import { recordSupervisorContact, supervisorContactFromEvent } from "../supervisor-contact.ts";
71
- import type { TaskExecutionResult } from "./post-execution.ts";
71
+ import type { ResultSource, TaskExecutionResult } from "./post-execution.ts";
72
72
  import type { StreamBridgeHandle, TaskExecutionContext } from "./pre-execution.ts";
73
73
  import { applyAgentProgressEvent, applyUsageToProgress, progressEventSummary, shouldFlushProgressEvent } from "./progress.ts";
74
74
  import { cleanResultText, isFinalChildEvent } from "./result-utils.ts";
@@ -987,6 +987,20 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
987
987
  });
988
988
  }
989
989
  }
990
+ // Finding #2 (2026-09-29 battery): tag WHICH fallback branch produced the
991
+ // artifact so post-execution can fail stderr/none-sourced results
992
+ // deterministically — the noise classifier alone is not a verdict oracle.
993
+ // Order/semantics are identical to the previous `??` chain: first branch
994
+ // whose cleaned text is non-empty wins; otherwise the placeholder ("none").
995
+ const resultCandidates: ReadonlyArray<{ source: ResultSource; text: string | undefined }> = [
996
+ { source: "rawFinalText", text: cleanResultText(rawFinalText) },
997
+ { source: "finalText", text: cleanResultText(parsedOutput?.finalText) },
998
+ { source: "stdout", text: cleanResultText(finalStdout) },
999
+ { source: "stderr", text: cleanResultText(finalStderr) },
1000
+ { source: "findings", text: cleanResultText(intermediateFindings) },
1001
+ ];
1002
+ const usedResult = resultCandidates.find((candidate) => candidate.text);
1003
+ const resultSource: ResultSource = usedResult?.source ?? "none";
990
1004
  const resultArtifact = writeArtifact(manifest.artifactsRoot, {
991
1005
  kind: "result",
992
1006
  relativePath: `results/${task.id}.txt`,
@@ -995,14 +1009,10 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
995
1009
  // transcript's 16K compaction — this is the authoritative worker output.
996
1010
  // Fall back to transcript-derived finalText, then stdout/stderr, so a
997
1011
  // missing raw capture (mock/error path) never yields empty/garbage.
998
- cleanResultText(rawFinalText) ??
999
- cleanResultText(parsedOutput?.finalText) ??
1000
- cleanResultText(finalStdout) ??
1001
- cleanResultText(finalStderr) ??
1012
+ usedResult?.text ??
1002
1013
  // #7 hardening: if all real output paths are empty (worker exhausted
1003
1014
  // budget on tool calls, no assistant text), use intermediate findings.
1004
1015
  // intermediateFindings captures the last N tool-result display lines.
1005
- cleanResultText(intermediateFindings) ??
1006
1016
  "(no output)",
1007
1017
  producer: task.id,
1008
1018
  });
@@ -1094,6 +1104,7 @@ export async function runChildProcessTask(ctx: TaskExecutionContext): Promise<Ta
1094
1104
  parsedOutput,
1095
1105
  finalStdout,
1096
1106
  rawFinalText,
1107
+ resultSource,
1097
1108
  transcriptPath,
1098
1109
  terminalEvidence,
1099
1110
  startupEvidence,