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/CHANGELOG.md +23 -0
- package/dist/index.mjs +457 -456
- package/package.json +1 -1
- package/src/extension/team-tool/run-deadline.ts +22 -1
- package/src/extension/team-tool/run.ts +38 -2
- package/src/extension/team-tool-types.ts +4 -0
- package/src/extension/team-tool.ts +31 -3
- package/src/runtime/broker/protocol/lock-busy.ts +47 -1
- package/src/runtime/budget-enforcement.ts +36 -20
- package/src/runtime/merge-loop.ts +63 -35
- package/src/runtime/run-tracker.ts +28 -0
- package/src/state/contracts.ts +5 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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: ${
|
|
916
|
-
`
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 {
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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());
|
package/src/state/contracts.ts
CHANGED
|
@@ -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;
|