@tea-agent/loop-agent 0.44.0-next.10 → 0.44.0-next.12
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 +11 -0
- package/dist/build-stamp.json +3 -3
- package/dist/executors/dag-pi/sessions/index.js +6 -0
- package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
- package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
- package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
- package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
- package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
- package/dist/executors/dag-pi-executor.js +12 -2075
- package/dist/executors/shell-executor.js +39 -11
- package/dist/worker/console/routes.js +5 -0
- package/dist/worker/console/static/app-icon.svg +39 -0
- package/dist/worker/console/static/index.html +3 -0
- package/dist/worker/console/static/manifest.webmanifest +19 -0
- package/dist/worker/observe/static/console-theme.js +38 -0
- package/dist/workflows/dag/checkpoint.js +686 -0
- package/dist/workflows/dag/frontend-repair.js +24 -0
- package/dist/workflows/dag/frontend-review-scopes.js +10 -2
- package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
- package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
- package/dist/workflows/dag/hybrid/sources.js +1812 -0
- package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
- package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
- package/dist/workflows/dag/hybrid/templates/index.js +8 -0
- package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
- package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
- package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
- package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
- package/dist/workflows/dag/hybrid/types.js +32 -0
- package/dist/workflows/dag/init-hybrid.js +42 -7162
- package/dist/workflows/dag/runner.js +11 -1178
- package/dist/workflows/dag/terminal-status.js +502 -0
- package/docs/architecture/dag-execution.md +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/** DAG runner checkpoint 生命周期:rank 调度、中断归属、恢复 lineage 与 state 写入的显式生命周期。 */
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { isHardBudgetBreached } from "../../application/evaluation/budget.js";
|
|
6
|
+
import { preflightBudgetOrBreach, recordFinishedNodeBudget, validateCumulativeBudgetSuccessor, writeBudgetLedgerArtifacts } from "./budget-enforcement.js";
|
|
7
|
+
import { locateDagRun, readDagRunState } from "./lifecycle.js";
|
|
8
|
+
import { markInterruptNeedsReconcile, mergeAbortSignals, readInterruptRequest, runnerIdentityFromState, settleDagInterrupt, startInterruptWatcher } from "./interrupt-request.js";
|
|
9
|
+
import { moveToCompletedRunDir, moveToPausedRunDir, writeRunState } from "./run-store.js";
|
|
10
|
+
import { evaluateNodeLiveness, resolveLivenessPolicy } from "./liveness-policy.js";
|
|
11
|
+
import { createDagNodeExecutor } from "./executor-registry.js";
|
|
12
|
+
import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
|
|
13
|
+
import { relocateConvergenceArtifactPaths, relocateRunArtifactPaths } from "./upstream-artifacts.js";
|
|
14
|
+
import { WORKSPACE_CHECKPOINT_TERMINAL_REL, captureWorkspaceCheckpoint, writeWorkspaceCheckpoint } from "./workspace-checkpoint.js";
|
|
15
|
+
import { executeDagNode } from "./node-execution.js";
|
|
16
|
+
import { runConvergencePassController } from "./convergence/controller.js";
|
|
17
|
+
import { executeDagRanksOnce } from "./scheduler.js";
|
|
18
|
+
import { bindFrontendShapeStateToRunTopology, commitFrontendShapeTransitionCas, commitRuntimeFrontendShapeFact, isCanonicalFrontendShapeDag } from "./frontend-shape-facts.js";
|
|
19
|
+
import { computeFrontendShapeSourceDigest } from "./frontend-shape.js";
|
|
20
|
+
import { ensureFrontendShapeCapsuleRecord } from "./frontend-shape-capsule-store.js";
|
|
21
|
+
import { executeDynamicCondition } from "./dynamic-runtime/condition.js";
|
|
22
|
+
import { executeDynamicLoopUntil } from "./dynamic-runtime/loop-until.js";
|
|
23
|
+
import { executeDynamicMapExpansion } from "./dynamic-runtime/map.js";
|
|
24
|
+
import { executeDynamicReduction } from "./dynamic-runtime/reduction.js";
|
|
25
|
+
import { installRunnerExitDiagnostics } from "./runner-exit-diagnostics.js";
|
|
26
|
+
import { buildRecoveryResultForTrigger, createRecoveryOperation, finalizeTerminalRunStatus, } from "./terminal-status.js";
|
|
27
|
+
export async function assertRecoveredFrontendLineage(input) {
|
|
28
|
+
const maxContinuations = Math.max(1, Math.min(5, input.maxContinuations));
|
|
29
|
+
let parentRunId = input.rootRunId;
|
|
30
|
+
let recovery = input.recovery;
|
|
31
|
+
const recoveryRootRunId = recovery.recoveryRootRunId;
|
|
32
|
+
const visited = new Set();
|
|
33
|
+
if (recoveryRootRunId !== input.rootRunId) {
|
|
34
|
+
throw new Error("recovery root lineage binding mismatch");
|
|
35
|
+
}
|
|
36
|
+
for (let depth = 0; depth < maxContinuations; depth += 1) {
|
|
37
|
+
// The persisted parent intent represents the edge about to be traversed,
|
|
38
|
+
// so its counters equal the number of already-completed continuation edges.
|
|
39
|
+
// Each completed child increments both counters exactly once; no edge may
|
|
40
|
+
// exceed the frozen DAG quota.
|
|
41
|
+
if (recovery.continuationCount !== depth ||
|
|
42
|
+
recovery.attemptIndex !== depth) {
|
|
43
|
+
throw new Error("recovery child lineage counters are not monotonic");
|
|
44
|
+
}
|
|
45
|
+
if (recovery.phase !== "child-running" ||
|
|
46
|
+
recovery.parentRunId !== parentRunId ||
|
|
47
|
+
recovery.recoveryRootRunId !== recoveryRootRunId ||
|
|
48
|
+
!recovery.childRunId ||
|
|
49
|
+
(depth === 0
|
|
50
|
+
? recovery.requestId !== input.rootRequestId
|
|
51
|
+
: recovery.requestId !== parentRunId)) {
|
|
52
|
+
throw new Error("persisted recovery lineage is missing or unbound");
|
|
53
|
+
}
|
|
54
|
+
if (visited.has(recovery.childRunId)) {
|
|
55
|
+
throw new Error("recovery child lineage contains a cycle");
|
|
56
|
+
}
|
|
57
|
+
visited.add(recovery.childRunId);
|
|
58
|
+
const child = await locateDagRun(input.cwd, recovery.childRunId);
|
|
59
|
+
if (!child || child.lifecycle !== "completed") {
|
|
60
|
+
throw new Error("recovery child is not completed");
|
|
61
|
+
}
|
|
62
|
+
const childState = await readDagRunState(child.runDir);
|
|
63
|
+
if (childState.runId !== recovery.childRunId) {
|
|
64
|
+
throw new Error("recovery child state binding mismatch");
|
|
65
|
+
}
|
|
66
|
+
if (childState.status === "finished")
|
|
67
|
+
return;
|
|
68
|
+
if (childState.frontendRecoveryResult?.outcome !== "recovered" ||
|
|
69
|
+
!childState.frontendRecoveryState) {
|
|
70
|
+
throw new Error("recovery child terminal state is not successful");
|
|
71
|
+
}
|
|
72
|
+
parentRunId = childState.runId;
|
|
73
|
+
recovery = childState.frontendRecoveryState;
|
|
74
|
+
}
|
|
75
|
+
throw new Error("recovery child lineage exceeds the continuation quota");
|
|
76
|
+
}
|
|
77
|
+
export async function finalizeRunOwnedInterrupt(input) {
|
|
78
|
+
const request = await readInterruptRequest(input.runDir);
|
|
79
|
+
if (!request)
|
|
80
|
+
return;
|
|
81
|
+
if (request.status === "settled" || request.status === "needs-reconcile") {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const hasUnconfirmed = Object.values(input.state.nodes).some((node) => node.failureCategory === "termination-unconfirmed");
|
|
85
|
+
if (hasUnconfirmed || input.paused) {
|
|
86
|
+
await markInterruptNeedsReconcile({
|
|
87
|
+
runDir: input.runDir,
|
|
88
|
+
outcome: hasUnconfirmed
|
|
89
|
+
? "termination-unconfirmed"
|
|
90
|
+
: "paused-before-settle",
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (input.interruptAborted &&
|
|
95
|
+
request.status === "acknowledged" &&
|
|
96
|
+
(input.state.status === "failed" ||
|
|
97
|
+
input.state.status === "partial_failed" ||
|
|
98
|
+
input.state.failureCategory === "controller-interrupted")) {
|
|
99
|
+
await settleDagInterrupt({
|
|
100
|
+
runDir: input.runDir,
|
|
101
|
+
outcome: "controller-interrupted",
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (request.status === "requested") {
|
|
106
|
+
await markInterruptNeedsReconcile({
|
|
107
|
+
runDir: input.runDir,
|
|
108
|
+
outcome: "runner-did-not-acknowledge",
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
await markInterruptNeedsReconcile({
|
|
113
|
+
runDir: input.runDir,
|
|
114
|
+
outcome: "run-exited-before-interrupt-settled",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
export function buildRankAwareExecuteNode(input) {
|
|
118
|
+
if (input.customExecuteNode) {
|
|
119
|
+
return input.baseExecuteNode;
|
|
120
|
+
}
|
|
121
|
+
return async (nodeInput) => {
|
|
122
|
+
const isPiWriter = nodeInput.task.executor === "pi" &&
|
|
123
|
+
nodeInput.task.toolProfile === "write" &&
|
|
124
|
+
Boolean(nodeInput.model);
|
|
125
|
+
if (!isPiWriter) {
|
|
126
|
+
return input.baseExecuteNode(nodeInput);
|
|
127
|
+
}
|
|
128
|
+
const siblingWriteSets = nodeInput.task.writePolicy === "exclusive"
|
|
129
|
+
? concurrentSiblingWriteSetsForNode(input.rankWriterNodeIds, nodeInput.task.id, input.tasksById)
|
|
130
|
+
: [];
|
|
131
|
+
return executeDagPiNode({ ...nodeInput, model: nodeInput.model }, {
|
|
132
|
+
...input.meta,
|
|
133
|
+
concurrentSiblingWriteSets: siblingWriteSets.length > 0 ? siblingWriteSets : undefined,
|
|
134
|
+
writeGuardAttribution: siblingWriteSets.length > 0 ? "best-effort" : "per-node",
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export async function executeDagCheckpoint(input) {
|
|
139
|
+
const { spec, state, ranks, runDir: initialRunDir, cwd, maxConcurrent, completedRunDir, pausedRunDir, } = input;
|
|
140
|
+
bindFrontendShapeStateToRunTopology(state);
|
|
141
|
+
let runDir = initialRunDir;
|
|
142
|
+
let stateWriteQueue = Promise.resolve();
|
|
143
|
+
let shapeTransitionBarrier;
|
|
144
|
+
const persistState = async (options) => {
|
|
145
|
+
if (shapeTransitionBarrier)
|
|
146
|
+
await shapeTransitionBarrier;
|
|
147
|
+
stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, state, options));
|
|
148
|
+
await stateWriteQueue;
|
|
149
|
+
};
|
|
150
|
+
const runnerLivenessPolicy = resolveLivenessPolicy(spec.defaults?.livenessPolicy);
|
|
151
|
+
const heartbeatTimer = setInterval(() => {
|
|
152
|
+
if (!state.runner)
|
|
153
|
+
return;
|
|
154
|
+
// Runner lease only — never counts as meaningful Pi progress.
|
|
155
|
+
state.runner.heartbeatAt = new Date().toISOString();
|
|
156
|
+
const tasksByIdForPolicy = new Map(spec.tasks.map((task) => [task.id, task]));
|
|
157
|
+
for (const node of Object.values(state.nodes)) {
|
|
158
|
+
if (node.status !== "RUNNING")
|
|
159
|
+
continue;
|
|
160
|
+
// Lease clock is separate from meaningful activity.
|
|
161
|
+
node.lastLeaseAt = state.runner.heartbeatAt;
|
|
162
|
+
const task = tasksByIdForPolicy.get(node.id);
|
|
163
|
+
const policy = resolveLivenessPolicy(spec.defaults?.livenessPolicy, task?.livenessPolicy);
|
|
164
|
+
const evaluation = evaluateNodeLiveness({
|
|
165
|
+
node,
|
|
166
|
+
policy,
|
|
167
|
+
runnerHeartbeatAt: state.runner.heartbeatAt,
|
|
168
|
+
});
|
|
169
|
+
node.livenessStatus = evaluation.status;
|
|
170
|
+
// Intentionally do NOT refresh lastActivityAt / lastMeaningfulProgressAt
|
|
171
|
+
// from the runner lease timer.
|
|
172
|
+
}
|
|
173
|
+
void persistState().catch(() => { });
|
|
174
|
+
}, runnerLivenessPolicy.heartbeatIntervalMs);
|
|
175
|
+
heartbeatTimer.unref();
|
|
176
|
+
await persistState();
|
|
177
|
+
const interruptController = new AbortController();
|
|
178
|
+
const expectedRunnerIdentity = runnerIdentityFromState(state);
|
|
179
|
+
const interruptWatcher = expectedRunnerIdentity
|
|
180
|
+
? startInterruptWatcher({
|
|
181
|
+
runDir,
|
|
182
|
+
runId: state.runId,
|
|
183
|
+
expectedRunnerIdentity,
|
|
184
|
+
controller: interruptController,
|
|
185
|
+
})
|
|
186
|
+
: undefined;
|
|
187
|
+
const abortSignal = mergeAbortSignals(input.abortSignal, interruptController.signal);
|
|
188
|
+
// Graceful terminal persistence: an outer SIGTERM/SIGINT (operator hard
|
|
189
|
+
// timeout, supervision layer, or shell wall-clock) must not leave the run
|
|
190
|
+
// orphaned in RUNNING with a dead heartbeat. Persist a terminal failed
|
|
191
|
+
// state with an explicit terminalReason before exiting so downstream
|
|
192
|
+
// liveness/doctor tooling sees a diagnosable terminal instead of an orphan.
|
|
193
|
+
let terminalSignal;
|
|
194
|
+
const shutdownOnSignal = (signal) => {
|
|
195
|
+
if (terminalSignal)
|
|
196
|
+
return;
|
|
197
|
+
terminalSignal = signal;
|
|
198
|
+
clearInterval(heartbeatTimer);
|
|
199
|
+
if (state.runner)
|
|
200
|
+
state.runner.heartbeatAt = new Date().toISOString();
|
|
201
|
+
state.status = "failed";
|
|
202
|
+
state.finishedAt = new Date().toISOString();
|
|
203
|
+
state.terminalReason = `runner terminated by ${signal} before run completion`;
|
|
204
|
+
console.warn(`[run-dag] received ${signal}; persisting terminal state and exiting`);
|
|
205
|
+
const exit = () => process.exit(signal === "SIGINT" ? 130 : 143);
|
|
206
|
+
// Best-effort atomic persist with a hard exit deadline so a stuck write
|
|
207
|
+
// queue cannot keep the process alive past the supervisor's kill window.
|
|
208
|
+
const hardExit = setTimeout(exit, 5000);
|
|
209
|
+
hardExit.unref();
|
|
210
|
+
void persistState()
|
|
211
|
+
.catch(() => { })
|
|
212
|
+
.finally(() => {
|
|
213
|
+
clearTimeout(hardExit);
|
|
214
|
+
exit();
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
const onSigTerm = () => shutdownOnSignal("SIGTERM");
|
|
218
|
+
const onSigInt = () => shutdownOnSignal("SIGINT");
|
|
219
|
+
// Keep listeners installed until persistence completes. Pi dependencies use
|
|
220
|
+
// signal-exit, which recounts listeners during signal dispatch and re-sends
|
|
221
|
+
// the signal if a once-listener has already removed itself. terminalSignal
|
|
222
|
+
// above makes repeated signals idempotent; normal cleanup removes listeners.
|
|
223
|
+
process.on("SIGTERM", onSigTerm);
|
|
224
|
+
process.on("SIGINT", onSigInt);
|
|
225
|
+
// Exit-time diagnostics (silent-death forensics): write an `armed` line at
|
|
226
|
+
// checkpoint start and append process-exit / uncaught-exception /
|
|
227
|
+
// unhandled-rejection lines with a live state snapshot so an abnormal death
|
|
228
|
+
// (supervisor hard kill, wedged event loop, crash with lost stderr) leaves
|
|
229
|
+
// a durable trace next to state.json. Removed on clean terminal return.
|
|
230
|
+
const exitDiagnostics = installRunnerExitDiagnostics({
|
|
231
|
+
runDir,
|
|
232
|
+
runId: state.runId,
|
|
233
|
+
title: state.title,
|
|
234
|
+
snapshot: () => {
|
|
235
|
+
const heartbeatAt = state.runner?.heartbeatAt;
|
|
236
|
+
return {
|
|
237
|
+
status: state.status,
|
|
238
|
+
terminalReason: state.terminalReason,
|
|
239
|
+
failureCategory: state.failureCategory,
|
|
240
|
+
heartbeatAt,
|
|
241
|
+
heartbeatAgeMs: heartbeatAt === undefined
|
|
242
|
+
? undefined
|
|
243
|
+
: Math.max(0, Date.now() - Date.parse(heartbeatAt)),
|
|
244
|
+
running: Object.values(state.nodes)
|
|
245
|
+
.filter((node) => node.status === "RUNNING")
|
|
246
|
+
.map((node) => ({
|
|
247
|
+
id: node.id,
|
|
248
|
+
attempt: node.currentAttempt,
|
|
249
|
+
startedAt: node.startedAt,
|
|
250
|
+
lastMeaningfulProgressAt: node.lastMeaningfulProgressAt,
|
|
251
|
+
})),
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
try {
|
|
256
|
+
const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
|
|
257
|
+
const baseExecuteNode = input.executeNode ??
|
|
258
|
+
createDagNodeExecutor({
|
|
259
|
+
runDir,
|
|
260
|
+
runId: state.runId,
|
|
261
|
+
spec,
|
|
262
|
+
});
|
|
263
|
+
const executeDynamicNode = async (dynamicInput) => {
|
|
264
|
+
const { task } = dynamicInput;
|
|
265
|
+
if (task.dynamicExpansion) {
|
|
266
|
+
return executeDynamicMapExpansion({
|
|
267
|
+
...dynamicInput,
|
|
268
|
+
expansion: task.dynamicExpansion,
|
|
269
|
+
executeDynamicNode,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (task.dynamicCondition) {
|
|
273
|
+
return executeDynamicCondition({
|
|
274
|
+
task,
|
|
275
|
+
condition: task.dynamicCondition,
|
|
276
|
+
tasksById: dynamicInput.tasksById,
|
|
277
|
+
state: dynamicInput.state,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (task.dynamicLoopUntil) {
|
|
281
|
+
return executeDynamicLoopUntil({
|
|
282
|
+
...dynamicInput,
|
|
283
|
+
loop: task.dynamicLoopUntil,
|
|
284
|
+
executeDynamicNode,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return executeDynamicReduction({
|
|
288
|
+
task,
|
|
289
|
+
reduction: task.dynamicReduction,
|
|
290
|
+
state: dynamicInput.state,
|
|
291
|
+
runDir: dynamicInput.runDir,
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
const deferTerminalCloseout = state.convergence !== undefined && tasksById.has("closeout-pi");
|
|
295
|
+
const convergenceRanks = deferTerminalCloseout
|
|
296
|
+
? ranks
|
|
297
|
+
.map((rank) => rank.filter((nodeId) => nodeId !== "closeout-pi"))
|
|
298
|
+
.filter((rank) => rank.length > 0)
|
|
299
|
+
: ranks;
|
|
300
|
+
const terminalCloseoutRanks = deferTerminalCloseout
|
|
301
|
+
? ranks
|
|
302
|
+
.map((rank) => rank.filter((nodeId) => nodeId === "closeout-pi"))
|
|
303
|
+
.filter((rank) => rank.length > 0)
|
|
304
|
+
: [];
|
|
305
|
+
const canonicalFrontendSource = isCanonicalFrontendShapeDag(tasksById)
|
|
306
|
+
? await loadCanonicalFrontendTaskSourceBinding(cwd, spec)
|
|
307
|
+
: undefined;
|
|
308
|
+
const executeRanks = (selectedRanks) => executeDagRanksOnce({
|
|
309
|
+
state,
|
|
310
|
+
ranks: selectedRanks,
|
|
311
|
+
tasksById,
|
|
312
|
+
maxConcurrent,
|
|
313
|
+
persistState,
|
|
314
|
+
commitFrontendShapeTransition: async (request) => {
|
|
315
|
+
let releaseBarrier;
|
|
316
|
+
shapeTransitionBarrier = new Promise((resolve) => {
|
|
317
|
+
releaseBarrier = resolve;
|
|
318
|
+
});
|
|
319
|
+
try {
|
|
320
|
+
if (!canonicalFrontendSource) {
|
|
321
|
+
throw new Error("frontend shape transition requires canonical task source binding");
|
|
322
|
+
}
|
|
323
|
+
return await commitFrontendShapeTransitionCas({
|
|
324
|
+
state,
|
|
325
|
+
tasksById,
|
|
326
|
+
request,
|
|
327
|
+
taskSourceDigest: canonicalFrontendSource.sourceDigest,
|
|
328
|
+
ensureRecord: async (capsule, terminalOutcome) => {
|
|
329
|
+
await ensureFreshFrontendShapeCapsuleRecord({
|
|
330
|
+
cwd,
|
|
331
|
+
spec,
|
|
332
|
+
canonicalSource: canonicalFrontendSource,
|
|
333
|
+
parentRunId: state.runId,
|
|
334
|
+
terminalOutcome,
|
|
335
|
+
capsule,
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
persist: async (nextState) => {
|
|
339
|
+
stateWriteQueue = stateWriteQueue.then(() => writeRunState(runDir, nextState));
|
|
340
|
+
await stateWriteQueue;
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
releaseBarrier();
|
|
346
|
+
shapeTransitionBarrier = undefined;
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
runDir,
|
|
350
|
+
abortSignal: input.abortSignal,
|
|
351
|
+
createExecuteNodeForRank: (rankWriterNodeIds) => buildRankAwareExecuteNode({
|
|
352
|
+
baseExecuteNode,
|
|
353
|
+
customExecuteNode: input.executeNode,
|
|
354
|
+
rankWriterNodeIds,
|
|
355
|
+
tasksById,
|
|
356
|
+
meta: { runDir, runId: state.runId, spec },
|
|
357
|
+
}),
|
|
358
|
+
executeScheduledNode: async (nodeId, executeNode, onPause) => {
|
|
359
|
+
if (abortSignal?.aborted)
|
|
360
|
+
return;
|
|
361
|
+
if (isHardBudgetBreached(state.budgetLedger))
|
|
362
|
+
return;
|
|
363
|
+
const preBreach = preflightBudgetOrBreach(state);
|
|
364
|
+
if (preBreach && isHardBudgetBreached(state.budgetLedger)) {
|
|
365
|
+
await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
|
|
366
|
+
await persistState();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
await executeDagNode({
|
|
370
|
+
nodeId,
|
|
371
|
+
tasksById,
|
|
372
|
+
state,
|
|
373
|
+
spec,
|
|
374
|
+
cwd,
|
|
375
|
+
runDir,
|
|
376
|
+
executeNode,
|
|
377
|
+
executeDynamicNode,
|
|
378
|
+
observer: input.observer,
|
|
379
|
+
persistState,
|
|
380
|
+
onPause,
|
|
381
|
+
});
|
|
382
|
+
const node = state.nodes[nodeId];
|
|
383
|
+
if (node && (node.status === "FINISHED" || node.status === "ERROR")) {
|
|
384
|
+
if (node.status === "FINISHED") {
|
|
385
|
+
if (canonicalFrontendSource) {
|
|
386
|
+
await commitRuntimeFrontendShapeFact({
|
|
387
|
+
state,
|
|
388
|
+
tasksById,
|
|
389
|
+
sourceNodeId: nodeId,
|
|
390
|
+
taskId: canonicalFrontendSource.taskId,
|
|
391
|
+
requirementMarkdown: canonicalFrontendSource.requirementMarkdown,
|
|
392
|
+
constraintMarkdown: canonicalFrontendSource.constraintMarkdown,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
recordFinishedNodeBudget(state, node);
|
|
397
|
+
await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
|
|
398
|
+
await persistState();
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
let pausedByNodeId;
|
|
403
|
+
while (true) {
|
|
404
|
+
pausedByNodeId = await executeRanks(convergenceRanks);
|
|
405
|
+
if (state.frontendShapeState?.terminalOutcome) {
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
if (pausedByNodeId) {
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
if (isHardBudgetBreached(state.budgetLedger)) {
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
const convergenceDecision = await runConvergencePassController({
|
|
415
|
+
spec,
|
|
416
|
+
state,
|
|
417
|
+
tasksById,
|
|
418
|
+
runDir,
|
|
419
|
+
cwd,
|
|
420
|
+
persistState,
|
|
421
|
+
});
|
|
422
|
+
if (convergenceDecision.pausedByNodeId) {
|
|
423
|
+
pausedByNodeId = convergenceDecision.pausedByNodeId;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
if (convergenceDecision.retry) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
if (!pausedByNodeId &&
|
|
432
|
+
terminalCloseoutRanks.length > 0 &&
|
|
433
|
+
state.convergence?.terminalReason &&
|
|
434
|
+
!isHardBudgetBreached(state.budgetLedger)) {
|
|
435
|
+
// Convergence closeout is a terminal phase, not an ordinary descendant
|
|
436
|
+
// rank. Running it only after the controller records terminalReason avoids
|
|
437
|
+
// stale success handoffs on intermediate request-revision passes while
|
|
438
|
+
// still producing a truthful failure handoff for max-passes/non-retry.
|
|
439
|
+
pausedByNodeId = await executeRanks(terminalCloseoutRanks);
|
|
440
|
+
}
|
|
441
|
+
interruptWatcher?.stop();
|
|
442
|
+
// Stop heartbeats before terminal archive. A late heartbeat writing
|
|
443
|
+
// state.json under completed/ trips the completed-facts write guard and
|
|
444
|
+
// makes run-dag exit non-zero after every node already finished.
|
|
445
|
+
clearInterval(heartbeatTimer);
|
|
446
|
+
await stateWriteQueue;
|
|
447
|
+
state.finishedAt = new Date().toISOString();
|
|
448
|
+
const runDirBeforeTransfer = runDir;
|
|
449
|
+
await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
|
|
450
|
+
if (pausedByNodeId) {
|
|
451
|
+
state.status = "paused";
|
|
452
|
+
await finalizeRunOwnedInterrupt({
|
|
453
|
+
runDir,
|
|
454
|
+
state,
|
|
455
|
+
paused: true,
|
|
456
|
+
interruptAborted: interruptController.signal.aborted,
|
|
457
|
+
});
|
|
458
|
+
await persistState();
|
|
459
|
+
await notifyRunObserver(input.observer, "onRunFinish", state);
|
|
460
|
+
runDir = await moveToPausedRunDir(runDir, pausedRunDir);
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
const { recoveryPending, recovery: recoveryTrigger } = await finalizeTerminalRunStatus(state, spec.tasks.length, runDir, cwd, spec.frontendRecovery?.maxContinuations ?? 1);
|
|
464
|
+
await finalizeRunOwnedInterrupt({
|
|
465
|
+
runDir,
|
|
466
|
+
state,
|
|
467
|
+
paused: false,
|
|
468
|
+
interruptAborted: interruptController.signal.aborted,
|
|
469
|
+
});
|
|
470
|
+
await persistState();
|
|
471
|
+
if (recoveryPending && input.recovery && recoveryTrigger) {
|
|
472
|
+
// M6 recovery operation controller path: construct/reuse one recovery
|
|
473
|
+
// operation keyed by a deterministic clientRequestId and supervise it
|
|
474
|
+
// to terminal, instead of staging an in-process child run.
|
|
475
|
+
const created = await createRecoveryOperation({
|
|
476
|
+
cwd,
|
|
477
|
+
runDir,
|
|
478
|
+
spec,
|
|
479
|
+
state,
|
|
480
|
+
trigger: recoveryTrigger,
|
|
481
|
+
deps: input.recovery,
|
|
482
|
+
});
|
|
483
|
+
if (created.kind === "blocked") {
|
|
484
|
+
state.status = "failed";
|
|
485
|
+
state.terminalReason = created.reason;
|
|
486
|
+
state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
|
|
487
|
+
}
|
|
488
|
+
else if (created.kind === "conflict") {
|
|
489
|
+
// Same clientRequestId with a different payload → fail closed; never
|
|
490
|
+
// create a second recovery operation (AC-004).
|
|
491
|
+
state.status = "failed";
|
|
492
|
+
state.terminalReason =
|
|
493
|
+
"frontend recovery request-id reuse conflict";
|
|
494
|
+
state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
|
|
495
|
+
}
|
|
496
|
+
else if (created.kind === "lease-held") {
|
|
497
|
+
// A different recovery entrance has already durably claimed this
|
|
498
|
+
// parent. Do not "help" by running its operation: that would reopen
|
|
499
|
+
// the exact two-child-DAG race the lease prevents.
|
|
500
|
+
state.status = "failed";
|
|
501
|
+
state.terminalReason =
|
|
502
|
+
"frontend recovery parent lease already held";
|
|
503
|
+
state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, "auto-recovery-blocked");
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
const { runRecoveryOperationController } = await import("./frontend-recovery-controller.js");
|
|
507
|
+
const result = await runRecoveryOperationController({
|
|
508
|
+
operationId: created.operation.operationId,
|
|
509
|
+
parentRunId: state.runId,
|
|
510
|
+
observeOnly: created.kind === "observed",
|
|
511
|
+
deps: {
|
|
512
|
+
...input.recovery,
|
|
513
|
+
budget: input.recovery.budget ?? state.budget,
|
|
514
|
+
readLedgers: input.recovery.readLedgers ?? (async () => {
|
|
515
|
+
if (!state.budget)
|
|
516
|
+
return [];
|
|
517
|
+
const location = await locateDagRun(cwd, state.runId);
|
|
518
|
+
let current = await readDagRunState(location?.runDir ?? runDir);
|
|
519
|
+
const visited = new Set([current.runId]);
|
|
520
|
+
while (current.frontendRecoveryState?.parentRunId === current.runId && current.frontendRecoveryState.childRunId && current.frontendRecoveryState.childRunId !== current.runId) {
|
|
521
|
+
if (["child-staging", "child-activating"].includes(current.frontendRecoveryState.phase))
|
|
522
|
+
break;
|
|
523
|
+
const childId = current.frontendRecoveryState.childRunId;
|
|
524
|
+
if (visited.has(childId))
|
|
525
|
+
throw Error("FRONTEND_PROVIDER_BUDGET_LINEAGE_INVALID: cyclic recovery");
|
|
526
|
+
visited.add(childId);
|
|
527
|
+
const childLocation = await locateDagRun(cwd, childId);
|
|
528
|
+
if (!childLocation)
|
|
529
|
+
throw Error("FRONTEND_PROVIDER_BUDGET_LINEAGE_INVALID: missing recovery child");
|
|
530
|
+
const child = await readDagRunState(childLocation.runDir);
|
|
531
|
+
validateCumulativeBudgetSuccessor(current, child);
|
|
532
|
+
current = child;
|
|
533
|
+
}
|
|
534
|
+
if (!current.budgetLedger)
|
|
535
|
+
throw Error("FRONTEND_PROVIDER_BUDGET_INVALID: recovery ledger missing");
|
|
536
|
+
return [current.budgetLedger];
|
|
537
|
+
}),
|
|
538
|
+
failureOwner: input.recovery.failureOwner ??
|
|
539
|
+
recoveryTrigger.failureOwner,
|
|
540
|
+
protocolFailureReason: input.recovery.protocolFailureReason ??
|
|
541
|
+
recoveryTrigger.protocolFailureReason,
|
|
542
|
+
recoveryMode: input.recovery.recoveryMode ?? recoveryTrigger.recoveryMode,
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
// The recovery CLI stages its child by writing a fresh
|
|
546
|
+
// `frontendRecoveryState` into the parent run on disk. A successful
|
|
547
|
+
// operation without that bound lineage is not a successful recovery:
|
|
548
|
+
// fail closed instead of archiving a false-positive `recovered` result.
|
|
549
|
+
let recoveryLineageError;
|
|
550
|
+
if (result.terminalReason === "succeeded") {
|
|
551
|
+
try {
|
|
552
|
+
const persistedParentState = await readDagRunState(runDir);
|
|
553
|
+
const persistedRecovery = persistedParentState.frontendRecoveryState;
|
|
554
|
+
if (!persistedRecovery ||
|
|
555
|
+
persistedRecovery.phase !== "child-running" ||
|
|
556
|
+
persistedRecovery.parentRunId !== state.runId ||
|
|
557
|
+
persistedRecovery.requestId !== recoveryTrigger.requestId ||
|
|
558
|
+
!persistedRecovery.childRunId) {
|
|
559
|
+
throw new Error("persisted recovery lineage is missing or unbound");
|
|
560
|
+
}
|
|
561
|
+
await assertRecoveredFrontendLineage({
|
|
562
|
+
cwd,
|
|
563
|
+
rootRunId: state.runId,
|
|
564
|
+
rootRequestId: recoveryTrigger.requestId,
|
|
565
|
+
recovery: persistedRecovery,
|
|
566
|
+
maxContinuations: spec.frontendRecovery?.maxContinuations ?? 1,
|
|
567
|
+
});
|
|
568
|
+
state.frontendRecoveryState = structuredClone(persistedRecovery);
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
recoveryLineageError =
|
|
572
|
+
error instanceof Error ? error.message : String(error);
|
|
573
|
+
state.status = "failed";
|
|
574
|
+
state.terminalReason =
|
|
575
|
+
`frontend recovery lineage invalid: ${recoveryLineageError}`;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
state.frontendRecoveryResult = buildRecoveryResultForTrigger(state, recoveryTrigger, result.terminalReason === "succeeded" && !recoveryLineageError
|
|
579
|
+
? "recovered"
|
|
580
|
+
: "auto-recovery-blocked");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
await notifyRunObserver(input.observer, "onRunFinish", state);
|
|
584
|
+
if (input.beforeTerminalArchive) {
|
|
585
|
+
await input.beforeTerminalArchive({ runDir, state });
|
|
586
|
+
await persistState();
|
|
587
|
+
}
|
|
588
|
+
// Parent is always archived to completed/ — no active/ retention for an
|
|
589
|
+
// in-process child CAS anymore (recovery runs as a separate operation).
|
|
590
|
+
try {
|
|
591
|
+
const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
|
|
592
|
+
await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
|
|
596
|
+
}
|
|
597
|
+
runDir = await moveToCompletedRunDir(runDir, completedRunDir);
|
|
598
|
+
}
|
|
599
|
+
await relocateRunArtifactPaths({
|
|
600
|
+
runDir,
|
|
601
|
+
oldRunDir: runDirBeforeTransfer,
|
|
602
|
+
nodes: state.nodes,
|
|
603
|
+
});
|
|
604
|
+
if (state.convergence) {
|
|
605
|
+
relocateConvergenceArtifactPaths(state.convergence, runDirBeforeTransfer, runDir);
|
|
606
|
+
}
|
|
607
|
+
await writeBudgetLedgerArtifacts(runDir, state.budgetLedger);
|
|
608
|
+
await persistState({ allowCompletedFactsWrite: true });
|
|
609
|
+
return {
|
|
610
|
+
title: state.title,
|
|
611
|
+
runId: state.runId,
|
|
612
|
+
status: state.status,
|
|
613
|
+
ranks,
|
|
614
|
+
nodes: state.nodes,
|
|
615
|
+
runDir,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
clearInterval(heartbeatTimer);
|
|
620
|
+
interruptWatcher?.stop();
|
|
621
|
+
exitDiagnostics.stop();
|
|
622
|
+
process.removeListener("SIGTERM", onSigTerm);
|
|
623
|
+
process.removeListener("SIGINT", onSigInt);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
export function concurrentSiblingWriteSetsForNode(rankWriterNodeIds, nodeId, tasksById) {
|
|
627
|
+
if (rankWriterNodeIds.length <= 1)
|
|
628
|
+
return [];
|
|
629
|
+
return rankWriterNodeIds
|
|
630
|
+
.filter((id) => id !== nodeId)
|
|
631
|
+
.map((id) => tasksById.get(id)?.writeSet ?? [])
|
|
632
|
+
.filter((writeSet) => writeSet.length > 0);
|
|
633
|
+
}
|
|
634
|
+
export async function ensureFreshFrontendShapeCapsuleRecord(input) {
|
|
635
|
+
const freshSource = await loadCanonicalFrontendTaskSourceBinding(input.cwd, input.spec);
|
|
636
|
+
if (freshSource.taskId !== input.canonicalSource.taskId ||
|
|
637
|
+
freshSource.sourceDigest !== input.canonicalSource.sourceDigest) {
|
|
638
|
+
throw new Error("canonical frontend task source freshness binding drifted before transition commit");
|
|
639
|
+
}
|
|
640
|
+
await (input.ensureRecord ?? ensureFrontendShapeCapsuleRecord)({
|
|
641
|
+
repoRoot: input.cwd,
|
|
642
|
+
taskId: input.canonicalSource.taskId,
|
|
643
|
+
parentRunId: input.parentRunId,
|
|
644
|
+
terminalOutcome: input.terminalOutcome,
|
|
645
|
+
capsule: input.capsule,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
export async function loadCanonicalFrontendTaskSourceBinding(cwd, spec) {
|
|
649
|
+
const binding = spec.sourceBinding;
|
|
650
|
+
if (!binding?.taskId)
|
|
651
|
+
throw new Error("canonical frontend DAG requires sourceBinding.taskId");
|
|
652
|
+
const requirement = binding.sources.find((source) => source.kind === "requirement");
|
|
653
|
+
if (!requirement)
|
|
654
|
+
throw new Error("canonical frontend DAG requires a bound requirement source");
|
|
655
|
+
const constraint = binding.sources.find((source) => source.kind === "constraint");
|
|
656
|
+
const readFreshSource = async (source) => {
|
|
657
|
+
let bytes;
|
|
658
|
+
try {
|
|
659
|
+
bytes = await readFile(path.resolve(cwd, source.path));
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
throw new Error(`canonical frontend ${source.kind} source freshness read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
663
|
+
}
|
|
664
|
+
const actualSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
665
|
+
if (actualSha256 !== source.sha256) {
|
|
666
|
+
throw new Error(`canonical frontend ${source.kind} source sha256 drifted: expected ${source.sha256}, received ${actualSha256}`);
|
|
667
|
+
}
|
|
668
|
+
return bytes.toString("utf8");
|
|
669
|
+
};
|
|
670
|
+
const requirementMarkdown = await readFreshSource(requirement);
|
|
671
|
+
const constraintMarkdown = constraint ? await readFreshSource(constraint) : "";
|
|
672
|
+
return {
|
|
673
|
+
taskId: binding.taskId,
|
|
674
|
+
requirementMarkdown,
|
|
675
|
+
constraintMarkdown,
|
|
676
|
+
sourceDigest: computeFrontendShapeSourceDigest({ requirementMarkdown, constraintMarkdown }),
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
export async function notifyRunObserver(observer, event, state) {
|
|
680
|
+
try {
|
|
681
|
+
await observer?.[event]?.(state);
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// Observers are derived views; they must not affect canonical DAG execution.
|
|
685
|
+
}
|
|
686
|
+
}
|