pi-crew 0.11.12 → 0.11.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.12",
3
+ "version": "0.11.13",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -22,6 +22,24 @@ export interface RunDeadline {
22
22
  /** Fallback deadline when no config or param override is available: 1 hour. */
23
23
  export const DEFAULT_RUN_DEADLINE_MS = 3_600_000;
24
24
 
25
+ /** Options for resolveRunDeadline. */
26
+ export interface RunDeadlineOptions {
27
+ /** "abort" (default): arm the deadline timer that ABORTS the controller —
28
+ * deadline expiry cancels the run (direct-await paths: DWF, goal-loop,
29
+ * inline scaffold — the deadline IS the execution bound).
30
+ *
31
+ * "watch-only": do NOT arm the abort timer. The deadline bounds only the
32
+ * caller's WATCH (waitForRun timeoutMs). Genuine caller cancels
33
+ * (ctx.signal abort outside a session switch, startForegroundRun callback
34
+ * abort) STILL abort the controller and cancel the run — deadline expiry
35
+ * alone never does. RELIABILITY FIX 2026-10-10 (wave-2 chain incident, run
36
+ * team_20261010045718): the foreground lane's armed timer was aborting
37
+ * executeTeamRun's signal at the 60-minute watch boundary — chain steps
38
+ * reported "partial" AND the still-healthy child run was cancelled. The
39
+ * watch is a timer; the cancel must be a caller decision. */
40
+ timer?: "abort" | "watch-only";
41
+ }
42
+
25
43
  /**
26
44
  * Resolve a unified run deadline with a shared AbortController.
27
45
  *
@@ -49,6 +67,7 @@ export function resolveRunDeadline<T extends object>(
49
67
  ctx: Pick<TeamContext, "cwd" | "signal">,
50
68
  params: T,
51
69
  config?: PiTeamsConfig,
70
+ options?: RunDeadlineOptions,
52
71
  ): RunDeadline {
53
72
  const timeoutMs = (params as { timeoutMs?: number }).timeoutMs;
54
73
  const effectiveConfig = config ?? loadConfig(ctx.cwd).config;
@@ -94,8 +113,10 @@ export function resolveRunDeadline<T extends object>(
94
113
  // otherwise every run leaves a dangling 1h timer retaining ctx/params in closure.
95
114
  // Same abort-after-exit guard as above: the timer may fire after workers
96
115
  // already exited and their spawn-signal listeners are gone — Node can throw.
116
+ // "watch-only" (bug #2 fix): the timer is NOT armed — the deadline bounds the
117
+ // watch only, so the controller can only be aborted by a real caller cancel.
97
118
  let timer: NodeJS.Timeout | undefined;
98
- if (deadlineMs > 0) {
119
+ if (deadlineMs > 0 && options?.timer !== "watch-only") {
99
120
  timer = setTimeout(() => safeAbort(controller, "run-deadline.timer"), deadlineMs);
100
121
  timer.unref?.();
101
122
  }
@@ -71,7 +71,7 @@ import * as path from "node:path";
71
71
  import { t } from "../../i18n.ts";
72
72
  import { hasAsyncStartMarker } from "../../runtime/async-marker.ts";
73
73
  import { checkProcessLiveness, isActiveRunStatus } from "../../runtime/process-status.ts";
74
- import { registerRunPromise, waitForRun } from "../../runtime/run-tracker.ts";
74
+ import { isWaitForRunTimeoutError, registerRunPromise, waitForRun } from "../../runtime/run-tracker.ts";
75
75
  import { collectRunMetrics } from "../../state/stores/run-metrics.ts";
76
76
  import type { PiTeamsToolResult } from "../tool-result.ts";
77
77
  import { effectiveRunConfig } from "./config-patch.ts";
@@ -709,7 +709,14 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
709
709
  const executeWorkers = runtime.kind !== "scaffold";
710
710
  if (executeWorkers && ctx.startForegroundRun) {
711
711
  // CORE-8: unified deadline — resolves params > config > 1h default.
712
- const fgDeadline = resolveRunDeadline(ctx, params, executedConfig);
712
+ // RELIABILITY FIX 2026-10-10 (bug #2, wave-2 chain incident run
713
+ // team_20261010045718): the foreground deadline is WATCH-ONLY. The armed
714
+ // abort timer used to cancel executeTeamRun's signal at the watch
715
+ // boundary (60m default) — chain steps reported "partial" AND the
716
+ // still-healthy child run was cancelled. waitForRun's own timeout still
717
+ // bounds the watch (partial-watch result below); real caller cancels
718
+ // (ctx.signal / foreground-run callback abort) still cancel the run.
719
+ const fgDeadline = resolveRunDeadline(ctx, params, executedConfig, { timer: "watch-only" });
713
720
  // F1 register/await race fix (2026-09-12): the waitForRun below runs
714
721
  // immediately after startForegroundRun returns (void), while
715
722
  // executeTeamRunCore registers its promise only after several awaits —
@@ -842,6 +849,35 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
842
849
  });
843
850
  } catch (waitError: unknown) {
844
851
  const waitErrMsg = errorMessage(waitError);
852
+ // RELIABILITY FIX 2026-10-10 (bug #2): a waitForRun WATCH expiry is NOT a
853
+ // run failure — with the watch-only deadline the run keeps executing in
854
+ // the foreground lane (exactly like the detached case) and the async
855
+ // notifier reports completion later. Report a partial-watch instead of an
856
+ // error so chain wrappers map the still-running step to "partial"
857
+ // without anyone reading this as "the run was cancelled/failed".
858
+ if (isWaitForRunTimeoutError(waitError)) {
859
+ return result(
860
+ [
861
+ `pi-crew run still running (watch window expired): ${updatedManifest.runId}`,
862
+ `Team: ${team.name}`,
863
+ `Workflow: ${workflow.name}`,
864
+ `Watch: ${waitErrMsg} — the run was NOT cancelled and keeps executing.`,
865
+ "",
866
+ "You will be notified when the run finishes.",
867
+ `Check status with: team action=status runId=${updatedManifest.runId}`,
868
+ `Wait again with: team action=wait runId=${updatedManifest.runId}`,
869
+ `State: ${updatedManifest.stateRoot}`,
870
+ ].join("\n"),
871
+ {
872
+ action: "run",
873
+ status: "ok",
874
+ runId: updatedManifest.runId,
875
+ artifactsRoot: updatedManifest.artifactsRoot,
876
+ partialWatch: true,
877
+ },
878
+ false,
879
+ );
880
+ }
845
881
  return result(
846
882
  [
847
883
  `pi-crew run timed out or failed: ${updatedManifest.runId}`,
@@ -29,6 +29,10 @@ export interface TeamToolDetails {
29
29
  taskId?: string;
30
30
  questionId?: string;
31
31
  waiting?: boolean;
32
+ /** RELIABILITY FIX 2026-10-10 (bug #2): set when the foreground run's WATCH
33
+ * window expired (waitForRun timeout) — the run itself was NOT cancelled
34
+ * and keeps executing; the async notifier reports completion later. */
35
+ partialWatch?: boolean;
32
36
  /** Structured data for programmatic consumption (e.g. TUI widgets). */
33
37
  data?: Record<string, unknown>;
34
38
  }
@@ -493,7 +493,7 @@ async function resumeDynamicWorkflowRun(
493
493
  return result(
494
494
  [
495
495
  `Resumed dynamic-workflow run ${dwfResult.manifest.runId}.`,
496
- `Status: ${dwfResult.manifest.status}`,
496
+ `Status: ${dwfResult.manifest.status} — resumed execution returned`,
497
497
  dwfResult.manifest.summary ? `Result: ${dwfResult.manifest.summary}` : undefined,
498
498
  ]
499
499
  .filter((line): line is string => line !== undefined)
@@ -819,6 +819,13 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
819
819
  }
820
820
  : task,
821
821
  );
822
+ // RELIABILITY FIX 2026-10-10 (bug #3): count the tasks actually re-queued at
823
+ // resume so the result message can report what happened (work re-dispatched
824
+ // vs "run was already terminal, nothing to resume") instead of a bare
825
+ // "Status: completed" that reads as "everything finished instantly".
826
+ const requeuedCount = recovered.tasks.filter(
827
+ (task) => task.status === "failed" || task.status === "cancelled" || task.status === "skipped" || task.status === "running",
828
+ ).length;
822
829
  saveRunTasks(runtimeManifest, resetTasks);
823
830
  const replay = replayPendingMailboxMessages(runtimeManifest);
824
831
  await appendEventAsync(runtimeManifest.eventsPath, {
@@ -874,6 +881,7 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
874
881
  kind: "execute" as const,
875
882
  runtimeManifest: executingManifest,
876
883
  resetTasks,
884
+ requeuedCount,
877
885
  executeWorkers,
878
886
  resumeSkillOverride,
879
887
  runtime,
@@ -909,12 +917,32 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
909
917
  // Finding 8: resume is the legitimate terminal-exit flow.
910
918
  isResume: true,
911
919
  });
920
+ // RELIABILITY FIX 2026-10-10 (bug #3, wave-2/wave-3 observations): the old
921
+ // message printed a bare "Status: completed" which read as "the whole run
922
+ // just finished" even when the resume had re-queued real work that kept
923
+ // executing. Report the truth: what the resumed execution returned, how
924
+ // much work was re-queued at resume, and the per-status task breakdown.
925
+ const statusLine =
926
+ executed.manifest.status === "completed"
927
+ ? "completed — resumed execution finished (all tasks terminal)"
928
+ : executed.manifest.status === "running"
929
+ ? "running — resumed dispatch is still executing"
930
+ : `${executed.manifest.status} — resumed execution ended in this state`;
931
+ const taskCounts = executed.tasks.reduce<Record<string, number>>((acc, task) => {
932
+ acc[task.status] = (acc[task.status] ?? 0) + 1;
933
+ return acc;
934
+ }, {});
935
+ const countsText = Object.entries(taskCounts)
936
+ .map(([status, count]) => `${count} ${status}`)
937
+ .join(", ");
912
938
  return result(
913
939
  [
914
940
  `Resumed run ${executed.manifest.runId}.`,
915
- `Status: ${executed.manifest.status}`,
916
- `Tasks: ${executed.tasks.length}`,
941
+ `Status: ${statusLine}`,
942
+ `Resumed work: ${decision.requeuedCount} task(s) re-queued${decision.requeuedCount === 0 ? " (run was already terminal — nothing to resume)" : ""}`,
943
+ `Tasks: ${executed.tasks.length} (${countsText})`,
917
944
  `Artifacts: ${executed.manifest.artifactsRoot}`,
945
+ `Check live status with: team action=status runId=${executed.manifest.runId}`,
918
946
  ].join("\n"),
919
947
  {
920
948
  action: "resume",
@@ -16,7 +16,7 @@
16
16
  * Precedent: protocol/request-parsers.ts (M4/WI-4.1 extraction discipline).
17
17
  */
18
18
 
19
- import { withRunLockSync } from "../../../state/coordination/locks.ts";
19
+ import { withRunLock, withRunLockSync } from "../../../state/coordination/locks.ts";
20
20
  import type { TeamRunManifest } from "../../../state/types.ts";
21
21
 
22
22
  /** Default bounded retry schedule for run.lock contention (ms). Five
@@ -24,6 +24,19 @@ import type { TeamRunManifest } from "../../../state/types.ts";
24
24
  * detached runner's state flush — without parking the request. */
25
25
  export const DEFAULT_LOCK_BUSY_RETRY_DELAYS_MS: readonly number[] = [50, 100, 200, 400, 800];
26
26
 
27
+ /** Merge-site busy-retry schedule (ms) — RELIABILITY FIX 2026-10-10
28
+ * (run team_20261010100956 incident: background-runner killed by an
29
+ * unhandled rejection when mergeUnitResult's withRunLock lost a run.lock
30
+ * contention race against a concurrent writer). Longer than the broker
31
+ * default above: a merge under contention sits on the scheduler's critical
32
+ * path — the run is mid-flight and the settled unit is held in memory — so
33
+ * it is worth ~7.9s of back-off (6 attempts) before the busy error is
34
+ * allowed to propagate to the run-level error handler. Each attempt itself
35
+ * already waits up to staleMs*2 inside acquireLockWithRetryAsync for
36
+ * in-process holders; this schedule covers the LIVE FOREIGN holder case,
37
+ * which throws immediately. */
38
+ export const MERGE_LOCK_BUSY_RETRY_DELAYS_MS: readonly number[] = [100, 250, 500, 1000, 2000, 4000];
39
+
27
40
  /** Match the exact identity locks.ts throws for a LIVE holder on the
28
41
  * never-steal path (sync acquireLockWithRetry, its async twin, and the
29
42
  * lockCtx fallthrough): `Run '<basename>' is locked by another operation.`
@@ -74,3 +87,36 @@ export async function withRunLockBusyRetry<T>(
74
87
  function sleep(ms: number): Promise<void> {
75
88
  return new Promise((resolve) => setTimeout(resolve, ms));
76
89
  }
90
+
91
+ /** Async twin of withRunLockBusyRetry for the scheduler's merge sites
92
+ * (merge-loop.ts mergeUnitResult, budget-enforcement.ts
93
+ * terminaliseRunWithDrain): run `fn` under the ASYNC withRunLock with a
94
+ * bounded busy-retry, notifying `onRetry` before each back-off sleep so the
95
+ * call site can log a lock_retry event.
96
+ *
97
+ * Retry safety mirrors the sync twin: the busy throw is raised by the lock
98
+ * ACQUIRE before `fn` runs, and every nested state write inside the merge
99
+ * bodies is lockCtx-reentrant (same-context nesting never re-acquires), so a
100
+ * retry never re-executes a partially-run body. Any NON-busy error
101
+ * propagates unchanged — real faults (ENOSPC, EACCES, corruption) keep their
102
+ * current fatal path; only the transient contention identity is retried.
103
+ * When the schedule is exhausted the busy error is rethrown for the run-level
104
+ * handler to classify (bounded — never an infinite loop). */
105
+ export async function withRunLockBusyRetryAsync<T>(
106
+ manifest: TeamRunManifest,
107
+ delaysMs: readonly number[],
108
+ fn: () => Promise<T>,
109
+ onRetry?: (attempt: number, delayMs: number, error: Error) => void,
110
+ ): Promise<T> {
111
+ for (let attempt = 0; ; attempt++) {
112
+ try {
113
+ return await withRunLock(manifest, fn);
114
+ } catch (error) {
115
+ if (!isRunLockBusyError(error)) throw error;
116
+ const delay = delaysMs[attempt];
117
+ if (delay === undefined) throw error;
118
+ onRetry?.(attempt + 1, delay, error as Error);
119
+ await sleep(delay);
120
+ }
121
+ }
122
+ }
@@ -16,12 +16,12 @@
16
16
  * team-runner.ts would create a cycle).
17
17
  */
18
18
  import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
19
- import { withRunLock } from "../state/coordination/locks.ts";
20
19
  import { appendEventAsync } from "../state/event-log/event-log.ts";
21
20
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
22
21
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
23
22
  import { aggregateUsage, formatTokens } from "../state/usage.ts";
24
23
  import { logInternalError } from "../utils/internal-error.ts";
24
+ import { MERGE_LOCK_BUSY_RETRY_DELAYS_MS, withRunLockBusyRetryAsync } from "./broker/protocol/lock-busy.ts";
25
25
  import { saveCrewAgents } from "./crew-agent-records.ts";
26
26
  import { cancelNonTerminalTasks, markBlocked } from "./dispatch-batch.ts";
27
27
  import { isNonTerminalTaskStatus, mergeTaskUpdatesPreservingTerminal } from "./merge-gate.ts";
@@ -136,25 +136,41 @@ export async function terminaliseRunWithDrain(
136
136
  if (validResults.length > 0) {
137
137
  // Merge under the run lock — same pattern as mergeUnitResult:
138
138
  // flush pending writes, load disk state, merge artifacts + tasks,
139
- // save atomically.
140
- const mergeResult = await withRunLock(ctx.manifest, async () => {
141
- flushPendingAtomicWrites();
142
- const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
143
- const diskManifest = disk?.manifest ?? ctx.manifest;
144
- const reconciledArtifacts = mergeArtifacts([
145
- ...diskManifest.artifacts,
146
- ...validResults.flatMap((item) => item.manifest.artifacts),
147
- ]);
148
- const resultManifest = updateRunStatus(
149
- { ...diskManifest, artifacts: reconciledArtifacts },
150
- "running",
151
- "Merged in-flight results during failed-task abort.",
152
- );
153
- const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
154
- await saveRunManifestAsync(resultManifest);
155
- await saveRunTasksAsync(resultManifest, resultTasks);
156
- return { resultManifest, resultTasks };
157
- });
139
+ // save atomically. RELIABILITY FIX 2026-10-10: bare withRunLock here had
140
+ // the same contention-fatal flaw as mergeUnitResult (see merge-loop.ts) —
141
+ // a busy run.lock on this abort path would surface as an unhandled
142
+ // rejection downstream. Bounded busy-retry + run.lock_retry event now.
143
+ const mergeResult = await withRunLockBusyRetryAsync(
144
+ ctx.manifest,
145
+ MERGE_LOCK_BUSY_RETRY_DELAYS_MS,
146
+ async () => {
147
+ flushPendingAtomicWrites();
148
+ const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
149
+ const diskManifest = disk?.manifest ?? ctx.manifest;
150
+ const reconciledArtifacts = mergeArtifacts([
151
+ ...diskManifest.artifacts,
152
+ ...validResults.flatMap((item) => item.manifest.artifacts),
153
+ ]);
154
+ const resultManifest = updateRunStatus(
155
+ { ...diskManifest, artifacts: reconciledArtifacts },
156
+ "running",
157
+ "Merged in-flight results during failed-task abort.",
158
+ );
159
+ const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
160
+ await saveRunManifestAsync(resultManifest);
161
+ await saveRunTasksAsync(resultManifest, resultTasks);
162
+ return { resultManifest, resultTasks };
163
+ },
164
+ (attempt, delayMs, error) => {
165
+ // Best-effort observability only — a failed event append must not break the retry.
166
+ appendEventAsync(ctx.manifest.eventsPath, {
167
+ type: "run.lock_retry",
168
+ runId: ctx.manifest.runId,
169
+ message: `run.lock contention during abort drain merge (attempt ${attempt}); retrying in ${delayMs}ms: ${error.message}`,
170
+ data: { attempt, delayMs, site: "terminaliseRunWithDrain" },
171
+ }).catch(() => undefined);
172
+ },
173
+ );
158
174
  ctx.manifest = mergeResult.resultManifest;
159
175
  ctx.tasks = mergeResult.resultTasks;
160
176
  }
@@ -13,10 +13,11 @@
13
13
  * keeping it here avoids a merge-loop ↔ finalize-run import cycle.
14
14
  */
15
15
  import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
16
- import { withRunLock } from "../state/coordination/locks.ts";
16
+ import { appendEventAsync } from "../state/event-log/event-log.ts";
17
17
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
18
18
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
19
19
  import { classifyFatalFsError } from "../utils/fs-errno.ts";
20
+ import { MERGE_LOCK_BUSY_RETRY_DELAYS_MS, withRunLockBusyRetryAsync } from "./broker/protocol/lock-busy.ts";
20
21
  import { cancelNonTerminalTasks } from "./dispatch-batch.ts";
21
22
  import { mergeTaskUpdatesPreservingTerminal } from "./merge-gate.ts";
22
23
  import type { SchedulerContext, SchedulerDecision } from "./scheduler-context.ts";
@@ -86,40 +87,67 @@ export async function mergeUnitResult(ctx: SchedulerContext): Promise<SchedulerD
86
87
  // not the arbitrary order in which mapConcurrent returned results.
87
88
  // Read committed manifest from disk inside the lock so artifact merge is based
88
89
  // on committed state, not in-memory state that may differ from disk.
89
- const mergeResult = await withRunLock(ctx.manifest, async () => {
90
- // NEW-D1: flush any pending coalesced atomic writes before reading from
91
- // disk. Without this, a worker's async manifest save (coalesced by
92
- // atomic-write) may not be committed yet, causing a lost-update on the
93
- // merge read. flushPendingAtomicWrites forces all queued writes to disk.
94
- flushPendingAtomicWrites();
95
- const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
96
- const diskManifest = disk?.manifest ?? ctx.manifest;
97
- const diskArtifacts = diskManifest.artifacts;
98
- const reconciledArtifacts = mergeArtifacts([...diskArtifacts, ...validResults.map((item) => item.manifest.artifacts)].flat());
99
- // R15-2: only force "running" when the disk manifest is NON-terminal
100
- // (queued/planning/running/blocked). If the disk status is already terminal
101
- // (cancelled/failed/completed — an external cancel or reconciler write
102
- // landing during the batch), PRESERVE that terminal status: forcing
103
- // "running" would legally erase it (contracts.ts allows cancelled/failed/
104
- // completed → running) and the loop would never observe the disk-terminal
105
- // (CANCEL-1/CANCEL-2 only catch worker-reported cancel or signal abort).
106
- const mergedBase = { ...diskManifest, artifacts: reconciledArtifacts };
107
- const resultManifest = isRunTerminalPreserved(diskManifest.status)
108
- ? mergedBase
109
- : updateRunStatus(mergedBase, "running", "Merged task updates from parallel batch.");
110
- // CANCEL-1: use the freshly-loaded disk tasks as the merge base instead
111
- // of the in-memory `tasks` closure variable. The in-memory tasks reflect
112
- // only team-runner's view; an external cancel (handleCancel, background
113
- // race with SIGTERM arriving after cancel wrote but before merge ran)
114
- // writes 'cancelled' to disk.tasks — using disk.tasks as base preserves
115
- // that cancellation through the merge instead of overwriting it with the
116
- // stale in-memory view. disk was loaded inside this lock, so it reflects
117
- // the freshest committed state.
118
- const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
119
- await saveRunManifestAsync(resultManifest);
120
- await saveRunTasksAsync(resultManifest, resultTasks);
121
- return { resultManifest, resultTasks };
122
- });
90
+ // RELIABILITY FIX 2026-10-10 (run team_20261010100956 incident): the merge
91
+ // used a bare withRunLock. Under run.lock contention (a second writer —
92
+ // stale reconciler, resumed runner, or a slow ENOSPC-degraded writer —
93
+ // holding run.lock past the acquire retry budget) withRunLock throws the
94
+ // plain "Run 'run.lock' is locked by another operation." error, which
95
+ // escaped executeTeamRun's catch → rejectRunPromise → an UNHANDLED promise
96
+ // rejection (no waitForRun waiter in background-runner mode) → the runner's
97
+ // unhandledRejection guard killed the whole runner mid-run. The merge now
98
+ // retries transient contention with back-off (logging a run.lock_retry
99
+ // event per attempt); only after the bounded schedule is exhausted does
100
+ // the busy error propagate for the run-level handler to classify. Non-busy
101
+ // errors (real faults) keep their existing fatal path — this is NOT a
102
+ // swallow-all.
103
+ const mergeResult = await withRunLockBusyRetryAsync(
104
+ ctx.manifest,
105
+ MERGE_LOCK_BUSY_RETRY_DELAYS_MS,
106
+ async () => {
107
+ // NEW-D1: flush any pending coalesced atomic writes before reading from
108
+ // disk. Without this, a worker's async manifest save (coalesced by
109
+ // atomic-write) may not be committed yet, causing a lost-update on the
110
+ // merge read. flushPendingAtomicWrites forces all queued writes to disk.
111
+ flushPendingAtomicWrites();
112
+ const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
113
+ const diskManifest = disk?.manifest ?? ctx.manifest;
114
+ const diskArtifacts = diskManifest.artifacts;
115
+ const reconciledArtifacts = mergeArtifacts([...diskArtifacts, ...validResults.map((item) => item.manifest.artifacts)].flat());
116
+ // R15-2: only force "running" when the disk manifest is NON-terminal
117
+ // (queued/planning/running/blocked). If the disk status is already terminal
118
+ // (cancelled/failed/completed — an external cancel or reconciler write
119
+ // landing during the batch), PRESERVE that terminal status: forcing
120
+ // "running" would legally erase it (contracts.ts allows cancelled/failed/
121
+ // completed → running) and the loop would never observe the disk-terminal
122
+ // (CANCEL-1/CANCEL-2 only catch worker-reported cancel or signal abort).
123
+ const mergedBase = { ...diskManifest, artifacts: reconciledArtifacts };
124
+ const resultManifest = isRunTerminalPreserved(diskManifest.status)
125
+ ? mergedBase
126
+ : updateRunStatus(mergedBase, "running", "Merged task updates from parallel batch.");
127
+ // CANCEL-1: use the freshly-loaded disk tasks as the merge base instead
128
+ // of the in-memory `tasks` closure variable. The in-memory tasks reflect
129
+ // only team-runner's view; an external cancel (handleCancel, background
130
+ // race with SIGTERM arriving after cancel wrote but before merge ran)
131
+ // writes 'cancelled' to disk.tasks — using disk.tasks as base preserves
132
+ // that cancellation through the merge instead of overwriting it with the
133
+ // stale in-memory view. disk was loaded inside this lock, so it reflects
134
+ // the freshest committed state.
135
+ const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
136
+ await saveRunManifestAsync(resultManifest);
137
+ await saveRunTasksAsync(resultManifest, resultTasks);
138
+ return { resultManifest, resultTasks };
139
+ },
140
+ (attempt, delayMs, error) => {
141
+ // Best-effort observability only — a failed event append (e.g. the same
142
+ // degraded disk that caused the contention) must not break the retry.
143
+ appendEventAsync(ctx.manifest.eventsPath, {
144
+ type: "run.lock_retry",
145
+ runId: ctx.manifest.runId,
146
+ message: `run.lock contention during unit merge (attempt ${attempt}); retrying in ${delayMs}ms: ${error.message}`,
147
+ data: { attempt, delayMs, site: "mergeUnitResult" },
148
+ }).catch(() => undefined);
149
+ },
150
+ );
123
151
  ctx.manifest = mergeResult.resultManifest;
124
152
  ctx.tasks = mergeResult.resultTasks;
125
153
  ctx.settledMerge = { taskIds: completedUnit.taskIds, result: resultToMerge };
@@ -67,6 +67,20 @@ export function registerRunPromise(runId: string): ActiveRunPromise {
67
67
  reject = rej;
68
68
  });
69
69
  const entry: ActiveRunPromise = { promise, resolve, reject };
70
+ // RELIABILITY FIX 2026-10-10 (run team_20261010100956 incident): a run
71
+ // registered in a process with NO waitForRun waiter (background-runner
72
+ // mode — the runner awaits executeTeamRun directly, nothing races
73
+ // entry.promise) and later REJECTED (executeTeamRun's catch calls
74
+ // rejectRunPromise with the original error) left the rejection unobserved
75
+ // → Node unhandledRejection → the background-runner's guard aborted the
76
+ // whole runner AFTER its cleanup had already run (heartbeat stopped →
77
+ // stale-reconciler mass-cancelled every task). Attaching a no-op rejection
78
+ // handler marks the promise handled; consumers still observe the rejection
79
+ // through their own .then/.race attachments — this changes nothing for
80
+ // foreground waiters, it only stops a waiter-less rejection from killing
81
+ // the process. Run failures themselves remain fully reported through the
82
+ // normal executeTeamRun catch path (manifest failed + async.failed event).
83
+ promise.catch(() => undefined);
70
84
  activeRunPromises.set(runId, entry);
71
85
  return entry;
72
86
  }
@@ -128,6 +142,20 @@ export function rejectRunPromise(runId: string, reason: unknown): void {
128
142
  }
129
143
  }
130
144
 
145
+ /** Identity matcher for waitForRun's timeout error — thrown by BOTH wait
146
+ * paths (the promise-race fast path and the polling slow path) as
147
+ * `waitForRun timed out after <N>ms`. Callers use it to distinguish "the
148
+ * WATCH window expired — the run itself may still be executing" from real
149
+ * wait failures (run not found, corrupt state). RELIABILITY FIX 2026-10-10
150
+ * (bug #2): a watch expiry must be reported as a partial-watch, never
151
+ * conflated with a run failure. Message identity — run-tracker exports no
152
+ * error class (plain Error), mirroring lock-busy.ts's matcher precedent. */
153
+ const WAIT_FOR_RUN_TIMEOUT_MESSAGE = /^waitForRun timed out after \d+ms$/;
154
+
155
+ export function isWaitForRunTimeoutError(error: unknown): boolean {
156
+ return error instanceof Error && WAIT_FOR_RUN_TIMEOUT_MESSAGE.test(error.message);
157
+ }
158
+
131
159
  function raceRunPromise(entry: ActiveRunPromise, timeoutMs: number, deadline: number): Promise<RunWaitResult> {
132
160
  let timer: ReturnType<typeof setTimeout> | undefined;
133
161
  const remaining = Math.max(0, deadline - Date.now());
@@ -254,6 +254,11 @@ export const TEAM_EVENT_TYPES = [
254
254
  "run.exported",
255
255
  "run.forget_requested",
256
256
  "run.goal_achievement",
257
+ // RELIABILITY FIX 2026-10-10 (run team_20261010100956 incident): run.lock
258
+ // contention observed by a scheduler merge site (mergeUnitResult /
259
+ // terminaliseRunWithDrain) that is being retried with back-off — diagnostic
260
+ // only; never transitions run state.
261
+ "run.lock_retry",
257
262
  "run.resume_requested",
258
263
  // SDD-3 W-C G4 (team-tool.ts handleResume): forced resume of a FOREIGN-owned
259
264
  // run — ownership was bypassed with force:true (liveness was NOT bypassable;