@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi/sessions/index.js +6 -0
  4. package/dist/executors/dag-pi/sessions/plan-batches.js +280 -0
  5. package/dist/executors/dag-pi/sessions/plan-prompts.js +367 -0
  6. package/dist/executors/dag-pi/sessions/scout-parallel.js +197 -0
  7. package/dist/executors/dag-pi/sessions/segmented-plan.js +1184 -0
  8. package/dist/executors/dag-pi/sessions/writer-evidence.js +60 -0
  9. package/dist/executors/dag-pi-executor.js +12 -2075
  10. package/dist/executors/shell-executor.js +39 -11
  11. package/dist/worker/console/routes.js +5 -0
  12. package/dist/worker/console/static/app-icon.svg +39 -0
  13. package/dist/worker/console/static/index.html +3 -0
  14. package/dist/worker/console/static/manifest.webmanifest +19 -0
  15. package/dist/worker/observe/static/console-theme.js +38 -0
  16. package/dist/workflows/dag/checkpoint.js +686 -0
  17. package/dist/workflows/dag/frontend-repair.js +24 -0
  18. package/dist/workflows/dag/frontend-review-scopes.js +10 -2
  19. package/dist/workflows/dag/frontend-test-execution-evidence.js +163 -67
  20. package/dist/workflows/dag/frontend-test-framework-adapters.js +309 -0
  21. package/dist/workflows/dag/hybrid/sources.js +1812 -0
  22. package/dist/workflows/dag/hybrid/templates/backend-test.js +1807 -0
  23. package/dist/workflows/dag/hybrid/templates/frontend-test.js +702 -0
  24. package/dist/workflows/dag/hybrid/templates/frontend.js +1529 -0
  25. package/dist/workflows/dag/hybrid/templates/index.js +8 -0
  26. package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +449 -0
  27. package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +495 -0
  28. package/dist/workflows/dag/hybrid/templates/shared.js +101 -0
  29. package/dist/workflows/dag/hybrid/templates/standard.js +265 -0
  30. package/dist/workflows/dag/hybrid/types.js +32 -0
  31. package/dist/workflows/dag/init-hybrid.js +42 -7162
  32. package/dist/workflows/dag/runner.js +11 -1178
  33. package/dist/workflows/dag/terminal-status.js +502 -0
  34. package/docs/architecture/dag-execution.md +4 -4
  35. 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
+ }