@tea-agent/loop-agent 0.35.1 → 0.35.3

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 (52) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +51 -0
  3. package/README.md +1 -1
  4. package/bin/loop-agent.js +37 -1
  5. package/dist/build-stamp.json +6 -0
  6. package/dist/cli/program.js +2 -2
  7. package/dist/executors/dag-pi-executor.js +44 -0
  8. package/dist/shared/package-metadata.js +42 -0
  9. package/dist/worker/console/chat/assistant-content.js +121 -0
  10. package/dist/worker/console/chat/pi-runtime.js +17 -32
  11. package/dist/worker/console/chat/routes.js +9 -4
  12. package/dist/worker/console/chat/session-store.js +3 -0
  13. package/dist/worker/console/chat/shortcuts.js +9 -7
  14. package/dist/worker/console/chat/turn-process.js +70 -15
  15. package/dist/worker/console/chat/workspace-landing.js +1 -1
  16. package/dist/worker/console/static/assets/index-DRqZiQ7J.css +1 -0
  17. package/dist/worker/console/static/assets/index-DuVLjCIT.js +57 -0
  18. package/dist/worker/console/static/index.html +2 -2
  19. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
  20. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +24 -3
  21. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  22. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +14 -6
  23. package/dist/worker/console/static-src/operator-chat/useChatThread.js +12 -6
  24. package/dist/worker/console/static-src/operator-chat/useComposer.js +15 -1
  25. package/dist/worker/loop-agent/loop-agent-client.js +17 -3
  26. package/dist/worker/observability/read-model.js +20 -0
  27. package/dist/worker/observe/spec-evidence.js +3 -8
  28. package/dist/worker/observe/static/views/dag-inspector.js +6 -71
  29. package/dist/worker/preflight.js +2 -1
  30. package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
  31. package/dist/workflows/dag/contract-output-registry.js +14 -0
  32. package/dist/workflows/dag/contract-validator-registrations.js +8 -0
  33. package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
  34. package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
  35. package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
  36. package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
  37. package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
  38. package/dist/workflows/dag/frontend-recovery-run.js +539 -0
  39. package/dist/workflows/dag/frontend-repair.js +219 -18
  40. package/dist/workflows/dag/frontend-verification-trace.js +47 -32
  41. package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
  42. package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
  43. package/dist/workflows/dag/init-hybrid.js +49 -24
  44. package/dist/workflows/dag/node-execution.js +89 -0
  45. package/dist/workflows/dag/recovery-recommendation.js +58 -0
  46. package/dist/workflows/dag/runner.js +245 -11
  47. package/dist/workflows/dag/scheduler.js +257 -3
  48. package/dist/workflows/dag/types.js +130 -2
  49. package/harness.json +1 -1
  50. package/package.json +4 -3
  51. package/dist/worker/console/static/assets/index-HX1pbOyl.css +0 -1
  52. package/dist/worker/console/static/assets/index-M0BLEBfh.js +0 -56
@@ -0,0 +1,123 @@
1
+ import { mkdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { z } from "zod";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ /**
6
+ * Phase 6: frontend recovery root manifest — the single final read model for a
7
+ * frontend recovery chain. A "root" is the first physical run of a
8
+ * frontend-implementation attempt; its parent/child lineage is counted once
9
+ * under `recoveryRootRunId`. report/Console read the manifest for dedup and
10
+ * final-status display instead of scanning parent/child fields (P1-4).
11
+ */
12
+ export const FRONTEND_RECOVERY_ROOT_MANIFEST_SCHEMA_ID = "frontend-recovery-root-manifest-v1";
13
+ export const frontendRecoveryRootManifestSchema = z
14
+ .object({
15
+ schemaVersion: z.literal(1),
16
+ schemaId: z.literal(FRONTEND_RECOVERY_ROOT_MANIFEST_SCHEMA_ID),
17
+ recoveryRootRunId: z.string().min(1),
18
+ /** Terminal logical status for the whole chain (finished/partial_failed/failed). */
19
+ status: z.enum(["finished", "partial_failed", "failed"]),
20
+ outcome: z.enum([
21
+ "none",
22
+ "recovered",
23
+ "candidate-contract-invalid",
24
+ "prewrite-blocked",
25
+ "repair-exhausted",
26
+ "auto-recovery-blocked",
27
+ ]),
28
+ attempts: z.array(z
29
+ .object({
30
+ runId: z.string().min(1),
31
+ role: z.enum(["root", "child"]),
32
+ attemptIndex: z.number().int().min(0),
33
+ outcome: z.enum([
34
+ "none",
35
+ "recovered",
36
+ "candidate-contract-invalid",
37
+ "prewrite-blocked",
38
+ "repair-exhausted",
39
+ "auto-recovery-blocked",
40
+ ]),
41
+ status: z.enum(["finished", "partial_failed", "failed"]),
42
+ })
43
+ .strict()),
44
+ evidenceRefs: z.array(z
45
+ .object({
46
+ runId: z.string().min(1),
47
+ relativePath: z.string().min(1),
48
+ sha256: z.string().min(1),
49
+ })
50
+ .strict()),
51
+ createdAt: z.string().min(1),
52
+ })
53
+ .strict();
54
+ /** Relative path of the root manifest inside a run dir. */
55
+ export const FRONTEND_RECOVERY_ROOT_MANIFEST_REL = "contracts/frontend-recovery-root-manifest.json";
56
+ export function frontendRecoveryRootManifestAbsPath(runDir) {
57
+ return path.join(runDir, FRONTEND_RECOVERY_ROOT_MANIFEST_REL);
58
+ }
59
+ export async function writeFrontendRecoveryRootManifest(runDir, manifest) {
60
+ const abs = frontendRecoveryRootManifestAbsPath(runDir);
61
+ await mkdir(path.dirname(abs), { recursive: true });
62
+ await writeJsonAtomic(abs, manifest);
63
+ return abs;
64
+ }
65
+ export async function readFrontendRecoveryRootManifest(runDir) {
66
+ try {
67
+ const raw = JSON.parse(await readFile(frontendRecoveryRootManifestAbsPath(runDir), "utf8"));
68
+ const parsed = frontendRecoveryRootManifestSchema.safeParse(raw);
69
+ return parsed.success ? parsed.data : undefined;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ /**
76
+ * Build a root manifest from a converged root run's lineage. The manifest is the
77
+ * single authority for final status/outcome: parent keeps its raw failure status,
78
+ * the chain's logical status is only `finished` when a child finished and the
79
+ * outcome is `recovered`.
80
+ */
81
+ export function buildFrontendRecoveryRootManifest(input) {
82
+ const status = input.outcome === "recovered" ? "finished" : "failed";
83
+ return {
84
+ schemaVersion: 1,
85
+ schemaId: FRONTEND_RECOVERY_ROOT_MANIFEST_SCHEMA_ID,
86
+ recoveryRootRunId: input.recoveryRootRunId,
87
+ status,
88
+ outcome: input.outcome,
89
+ attempts: input.attempts,
90
+ evidenceRefs: input.evidenceRefs ?? [],
91
+ createdAt: input.createdAt ?? new Date().toISOString(),
92
+ };
93
+ }
94
+ /**
95
+ * Dedup a set of DAG runs by recovery root (P1-4): each root contributes exactly
96
+ * one summary; parent/child physical runs are folded into the root's attempts.
97
+ * A run with no `frontendRecoveryState` is its own single-attempt root.
98
+ */
99
+ export function aggregateFrontendRecoveryRoots(runs) {
100
+ const byRoot = new Map();
101
+ for (const run of runs) {
102
+ const rootId = run.frontendRecoveryState?.recoveryRootRunId ?? run.runId;
103
+ const list = byRoot.get(rootId) ?? [];
104
+ list.push(run);
105
+ byRoot.set(rootId, list);
106
+ }
107
+ const result = [];
108
+ for (const [rootId, list] of byRoot) {
109
+ const sorted = [...list].sort((a, b) => (a.frontendRecoveryState?.attemptIndex ?? 0) -
110
+ (b.frontendRecoveryState?.attemptIndex ?? 0));
111
+ // The root's displayed status is the last attempt's status, unless the
112
+ // root run itself finished (a child recovered only surfaces `finished`
113
+ // via the manifest, not via the raw root run status).
114
+ const last = sorted[sorted.length - 1];
115
+ result.push({
116
+ recoveryRootRunId: rootId,
117
+ status: last.status,
118
+ attemptCount: sorted.length,
119
+ runIds: sorted.map((run) => run.runId),
120
+ });
121
+ }
122
+ return result;
123
+ }
@@ -0,0 +1,539 @@
1
+ import { copyFile, mkdir, readdir, readFile, rm, access } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
4
+ import { sha256Hex } from "./frontend-implementation-contract.js";
5
+ import { FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH, FRONTEND_RECOVERY_INTENT_REL_DIR, FRONTEND_RECOVERY_STAGING_PREFIX, computeFrontendRecoveryPlan, computeFrontendRecoveryPlanForSource, } from "./frontend-recovery-plan.js";
6
+ import { dagRunDirExists, getDagRunDir, readDagRunSpec, readDagRunState, transferDagRunDir, writeDagRunState, } from "./lifecycle.js";
7
+ import { buildDagRunId } from "./runner.js";
8
+ import { prepareRunDir, writeNodeRecord } from "./run-store.js";
9
+ import { FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT, readFrontendPrewriteResult, } from "./scheduler.js";
10
+ import { topoSortToRanks } from "./topo.js";
11
+ /**
12
+ * Candidate-continuation child creation kernel (phase 3b).
13
+ *
14
+ * Parent terminal convergence (phase 3a) has already persisted a recovery
15
+ * intent on `DagRunState.frontendRecoveryState`. This module is the single
16
+ * writer that stages and commits one reserved child run per requestId, with a
17
+ * commit-point handoff: CAS-reserve childRunId → stage files (hash-verified) →
18
+ * rename into active/ → activation marker → three-way consistency check → CAS
19
+ * phase=child-running. Scheduling/execution of the child is phase 3c and out
20
+ * of scope here.
21
+ */
22
+ /** Canonical contract artifact default location; overridden by the prewrite
23
+ * gate's configured outputDir/artifactName when present. */
24
+ const FRONTEND_CANONICAL_CONTRACT_DEFAULT_REL_PATH = "contracts/frontend-implementation-contract.json";
25
+ function resolveDeps(deps) {
26
+ return {
27
+ readParentState: deps?.readParentState ?? readDagRunState,
28
+ writeParentState: deps?.writeParentState ?? writeDagRunState,
29
+ writeMarker: deps?.writeMarker ??
30
+ ((markerPath, marker) => writeJsonAtomic(markerPath, marker)),
31
+ };
32
+ }
33
+ function frontendRecoveryMarkerRelPath(parentRunDir, requestId) {
34
+ return path.join(parentRunDir, FRONTEND_RECOVERY_INTENT_REL_DIR, `${requestId}.json`);
35
+ }
36
+ async function hashFile(filePath) {
37
+ return sha256Hex(await readFile(filePath, "utf8"));
38
+ }
39
+ async function fileExists(filePath) {
40
+ try {
41
+ await access(filePath);
42
+ return true;
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ }
48
+ function isValidFrontendRecoveryMarker(raw) {
49
+ return (raw.schemaVersion === 1 &&
50
+ typeof raw.recoveryRootRunId === "string" &&
51
+ raw.recoveryRootRunId.length > 0 &&
52
+ typeof raw.parentRunId === "string" &&
53
+ raw.parentRunId.length > 0 &&
54
+ typeof raw.childRunId === "string" &&
55
+ raw.childRunId.length > 0 &&
56
+ typeof raw.requestId === "string" &&
57
+ raw.requestId.length > 0 &&
58
+ typeof raw.importManifestSha256 === "string" &&
59
+ /^[a-f0-9]{64}$/.test(raw.importManifestSha256));
60
+ }
61
+ /**
62
+ * Read + validate the activation marker. Missing or invalid markers return
63
+ * `undefined` so the phase-3c gate and tests can treat an unmarked active
64
+ * child as not executable (AC-2 / AC-5d).
65
+ */
66
+ export async function readFrontendRecoveryMarker(parentRunDir, requestId) {
67
+ let raw;
68
+ try {
69
+ raw = JSON.parse(await readFile(frontendRecoveryMarkerRelPath(parentRunDir, requestId), "utf8"));
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ if (typeof raw !== "object" || raw === null)
75
+ return undefined;
76
+ if (!isValidFrontendRecoveryMarker(raw)) {
77
+ return undefined;
78
+ }
79
+ return raw;
80
+ }
81
+ function classifyFrontendFactKind(nodeId) {
82
+ if (/prewrite/i.test(nodeId))
83
+ return "prewrite";
84
+ if (/scout/i.test(nodeId))
85
+ return "scout";
86
+ if (/contract/i.test(nodeId))
87
+ return "contract";
88
+ if (/plan/i.test(nodeId))
89
+ return "plan";
90
+ return "plan";
91
+ }
92
+ function resolveCanonicalContractRelPath(spec) {
93
+ for (const task of spec.tasks) {
94
+ const gate = task.shell?.frontendPrewriteGate;
95
+ if (gate) {
96
+ return path.posix.join(gate.outputDir, gate.artifactName);
97
+ }
98
+ }
99
+ return FRONTEND_CANONICAL_CONTRACT_DEFAULT_REL_PATH;
100
+ }
101
+ function normalizeRunRelativePath(relPath) {
102
+ return relPath.replace(/\\/g, "/");
103
+ }
104
+ function assertArtifactPathWithinParentRun(parentRunDir, artifactPath) {
105
+ if (!path.isAbsolute(artifactPath)) {
106
+ return normalizeRunRelativePath(artifactPath);
107
+ }
108
+ const resolved = path.resolve(artifactPath);
109
+ const parentResolved = path.resolve(parentRunDir);
110
+ if (resolved !== parentResolved &&
111
+ !resolved.startsWith(`${parentResolved}${path.sep}`)) {
112
+ throw new Error(`artifact path outside parent run directory: ${artifactPath}`);
113
+ }
114
+ return normalizeRunRelativePath(path.relative(parentResolved, resolved));
115
+ }
116
+ function collectNodeArtifactRelativePaths(record) {
117
+ const paths = new Set([`${record.id}.json`]);
118
+ const add = (value) => {
119
+ if (value?.trim())
120
+ paths.add(normalizeRunRelativePath(value.trim()));
121
+ };
122
+ add(record.stdoutArtifactPath);
123
+ add(record.assistantArtifactPath);
124
+ add(record.structuredArtifactPath);
125
+ add(record.nodeRecordPath);
126
+ add(record.escalationArtifactPath);
127
+ add(record.humanApprovalArtifactPath);
128
+ add(record.humanRejectionArtifactPath);
129
+ for (const attempt of record.attempts ?? []) {
130
+ add(attempt.artifactPath);
131
+ }
132
+ return [...paths];
133
+ }
134
+ async function listNodeDirectoryArtifacts(parentRunDir, nodeId) {
135
+ const nodeDir = path.join(parentRunDir, nodeId);
136
+ if (!(await fileExists(nodeDir)))
137
+ return [];
138
+ const entries = await readdir(nodeDir, { withFileTypes: true });
139
+ const artifacts = [];
140
+ for (const entry of entries) {
141
+ if (!entry.isFile())
142
+ continue;
143
+ artifacts.push(normalizeRunRelativePath(path.join(nodeId, entry.name)));
144
+ }
145
+ return artifacts;
146
+ }
147
+ async function copyArtifactVerified(input) {
148
+ const normalized = assertArtifactPathWithinParentRun(input.parentRunDir, input.relativePath);
149
+ const sourcePath = path.join(input.parentRunDir, ...normalized.split("/"));
150
+ if (!(await fileExists(sourcePath))) {
151
+ throw new Error(`missing imported artifact: ${normalized}`);
152
+ }
153
+ const expected = await hashFile(sourcePath);
154
+ const destinationPath = path.join(input.newRunDir, ...normalized.split("/"));
155
+ await mkdir(path.dirname(destinationPath), { recursive: true });
156
+ await copyFile(sourcePath, destinationPath);
157
+ const actual = await hashFile(destinationPath);
158
+ if (actual !== expected) {
159
+ throw new Error(`copy hash mismatch for artifact: ${normalized}`);
160
+ }
161
+ return { destinationRelativePath: normalized, sha256: actual };
162
+ }
163
+ function normalizeImportedArtifactPath(parentRunDir, newRunDir, artifactPath) {
164
+ if (!artifactPath?.trim())
165
+ return artifactPath;
166
+ const relative = assertArtifactPathWithinParentRun(parentRunDir, artifactPath);
167
+ return path.join(newRunDir, ...relative.split("/"));
168
+ }
169
+ function buildImportedNodeRecord(parentRecord, parentRunId, sourceNodeRecordSha256, parentRunDir, newRunDir) {
170
+ const { origin: _origin, ...rest } = parentRecord;
171
+ void _origin;
172
+ const imported = structuredClone(rest);
173
+ imported.nodeRecordPath = path.join(newRunDir, `${parentRecord.id}.json`);
174
+ for (const key of [
175
+ "stdoutArtifactPath",
176
+ "assistantArtifactPath",
177
+ "structuredArtifactPath",
178
+ "escalationArtifactPath",
179
+ "humanApprovalArtifactPath",
180
+ "humanRejectionArtifactPath",
181
+ ]) {
182
+ imported[key] = normalizeImportedArtifactPath(parentRunDir, newRunDir, imported[key]);
183
+ }
184
+ return {
185
+ ...imported,
186
+ origin: {
187
+ kind: "imported",
188
+ parentRunId,
189
+ sourceNodeRecordSha256,
190
+ },
191
+ };
192
+ }
193
+ function buildPendingNodeRecord(task) {
194
+ return {
195
+ id: task.id,
196
+ status: "PENDING",
197
+ executor: task.executor,
198
+ complexity: task.complexity,
199
+ origin: { kind: "executed" },
200
+ ...(task.outputMode ? { outputMode: task.outputMode } : {}),
201
+ };
202
+ }
203
+ async function importNodeFacts(input) {
204
+ const nodeJsonRel = `${input.nodeId}.json`;
205
+ const parentNodeJsonPath = path.join(input.parentRunDir, nodeJsonRel);
206
+ const sourceNodeRecordSha256 = (await fileExists(parentNodeJsonPath))
207
+ ? await hashFile(parentNodeJsonPath)
208
+ : sha256Hex(`${JSON.stringify(input.parentRecord, null, 2)}\n`);
209
+ const importedRecord = buildImportedNodeRecord(input.parentRecord, input.parentRunId, sourceNodeRecordSha256, input.parentRunDir, input.newRunDir);
210
+ await writeNodeRecord(input.newRunDir, input.nodeId, importedRecord);
211
+ const relativePaths = [
212
+ ...new Set([
213
+ ...collectNodeArtifactRelativePaths(input.parentRecord).filter((relativePath) => relativePath !== nodeJsonRel),
214
+ ...(await listNodeDirectoryArtifacts(input.parentRunDir, input.nodeId)),
215
+ ]),
216
+ ];
217
+ const artifacts = [
218
+ {
219
+ sourceRelativePath: nodeJsonRel,
220
+ destinationRelativePath: nodeJsonRel,
221
+ sha256: await hashFile(path.join(input.newRunDir, nodeJsonRel)),
222
+ },
223
+ ];
224
+ for (const relativePath of relativePaths.sort()) {
225
+ const copied = await copyArtifactVerified({
226
+ parentRunDir: input.parentRunDir,
227
+ newRunDir: input.newRunDir,
228
+ relativePath,
229
+ });
230
+ artifacts.push({
231
+ sourceRelativePath: relativePath,
232
+ destinationRelativePath: copied.destinationRelativePath,
233
+ sha256: copied.sha256,
234
+ });
235
+ }
236
+ return {
237
+ fact: {
238
+ nodeId: input.nodeId,
239
+ kind: classifyFrontendFactKind(input.nodeId),
240
+ artifacts,
241
+ },
242
+ importedRecord,
243
+ };
244
+ }
245
+ /** Write JSON atomically, then re-read the bytes and verify the hash matches
246
+ * the exact serialization we intended (AC-1 per-file hash verification). */
247
+ async function writeJsonVerified(runDir, relPath, value) {
248
+ const expectedJson = `${JSON.stringify(value, null, 2)}\n`;
249
+ const targetPath = path.join(runDir, ...relPath.split("/"));
250
+ await writeJsonAtomic(targetPath, value);
251
+ const actual = await hashFile(targetPath);
252
+ const expected = sha256Hex(expectedJson);
253
+ if (actual !== expected) {
254
+ throw new Error(`hash verification failed after writing ${relPath}`);
255
+ }
256
+ return actual;
257
+ }
258
+ async function casUpdateFrontendRecoveryState(parentRunDir, expectedRevision, deps, mutate) {
259
+ const state = await deps.readParentState(parentRunDir);
260
+ const recovery = state.frontendRecoveryState;
261
+ if (!recovery) {
262
+ throw new Error("parent frontend recovery intent missing during CAS update");
263
+ }
264
+ if (recovery.revision !== expectedRevision) {
265
+ throw new Error(`frontend recovery revision drifted (expected ${expectedRevision}, found ${recovery.revision}); refuse to continue`);
266
+ }
267
+ mutate(recovery);
268
+ recovery.revision = expectedRevision + 1;
269
+ state.frontendRecoveryState = recovery;
270
+ await deps.writeParentState(parentRunDir, state);
271
+ }
272
+ async function materializeChildStaging(input) {
273
+ const { cwd, parentRunDir, parentRunId, parentSpec, parentState, recovery, requestId, childRunId, stagingDir, createdAt, } = input;
274
+ await prepareRunDir(stagingDir);
275
+ await writeJsonVerified(stagingDir, "run.json", parentSpec);
276
+ // Phase 5: writer transient partial-write recovery resets the writer subtree
277
+ // (frontend-implement-pi) instead of the candidate producer; everything
278
+ // upstream is imported as verified parent facts.
279
+ let plan;
280
+ if (recovery.failureSource) {
281
+ plan = computeFrontendRecoveryPlanForSource({
282
+ spec: parentSpec,
283
+ parentRunId,
284
+ requestId,
285
+ failureSource: recovery.failureSource,
286
+ });
287
+ }
288
+ else {
289
+ const prewriteRead = await readFrontendPrewriteResult(parentRunDir);
290
+ if (!prewriteRead.ok) {
291
+ throw new Error(prewriteRead.reason);
292
+ }
293
+ plan = computeFrontendRecoveryPlan({
294
+ spec: parentSpec,
295
+ parentRunId,
296
+ requestId,
297
+ result: prewriteRead.result,
298
+ });
299
+ }
300
+ const { ranks } = topoSortToRanks(parentSpec);
301
+ const resetSet = new Set(plan.resetNodeIds);
302
+ const importedSet = new Set(plan.importedNodeIds);
303
+ const tasksById = new Map(parentSpec.tasks.map((task) => [task.id, task]));
304
+ const nodes = {};
305
+ const importedFacts = [];
306
+ for (const nodeId of plan.importedNodeIds) {
307
+ const parentRecord = parentState.nodes[nodeId];
308
+ if (!parentRecord) {
309
+ throw new Error(`parent node record missing for imported node: ${nodeId}`);
310
+ }
311
+ const { fact, importedRecord } = await importNodeFacts({
312
+ parentRunDir,
313
+ newRunDir: stagingDir,
314
+ parentRunId,
315
+ nodeId,
316
+ parentRecord,
317
+ });
318
+ importedFacts.push(fact);
319
+ nodes[nodeId] = importedRecord;
320
+ }
321
+ for (const nodeId of plan.resetNodeIds) {
322
+ const task = tasksById.get(nodeId);
323
+ if (!task) {
324
+ throw new Error(`reset node missing from spec: ${nodeId}`);
325
+ }
326
+ nodes[nodeId] = buildPendingNodeRecord(task);
327
+ }
328
+ for (const task of parentSpec.tasks) {
329
+ if (!resetSet.has(task.id) && !importedSet.has(task.id)) {
330
+ throw new Error(`node ${task.id} missing from frontend recovery plan partition`);
331
+ }
332
+ }
333
+ // Canonical contract + prewrite-result identity facts are copied alongside
334
+ // the per-node facts so reset nodes can re-read them (AC-4).
335
+ const canonicalContract = await copyArtifactVerified({
336
+ parentRunDir,
337
+ newRunDir: stagingDir,
338
+ relativePath: resolveCanonicalContractRelPath(parentSpec),
339
+ });
340
+ const prewriteResult = await copyArtifactVerified({
341
+ parentRunDir,
342
+ newRunDir: stagingDir,
343
+ relativePath: FRONTEND_PREWRITE_RESULT_SOURCE_ARTIFACT,
344
+ });
345
+ const childState = {
346
+ version: 1,
347
+ title: parentSpec.title,
348
+ runId: childRunId,
349
+ cwd,
350
+ startedAt: createdAt,
351
+ status: "pending",
352
+ ranks,
353
+ nodes,
354
+ frontendRecoveryState: {
355
+ schemaVersion: 1,
356
+ phase: "child-running",
357
+ requestId,
358
+ recoveryRootRunId: recovery.recoveryRootRunId,
359
+ parentRunId,
360
+ childRunId,
361
+ attemptId: recovery.attemptId,
362
+ attemptIndex: 1,
363
+ continuationCount: 1,
364
+ revision: 0,
365
+ },
366
+ ...(parentState.evaluation
367
+ ? { evaluation: structuredClone(parentState.evaluation) }
368
+ : {}),
369
+ };
370
+ await writeJsonVerified(stagingDir, "state.json", childState);
371
+ const importManifest = {
372
+ schemaVersion: 1,
373
+ parentRunId,
374
+ requestId,
375
+ createdAt,
376
+ canonicalContract: {
377
+ relativePath: canonicalContract.destinationRelativePath,
378
+ sha256: canonicalContract.sha256,
379
+ },
380
+ prewriteResult: {
381
+ relativePath: prewriteResult.destinationRelativePath,
382
+ sha256: prewriteResult.sha256,
383
+ },
384
+ importedFacts,
385
+ };
386
+ await writeJsonVerified(stagingDir, FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH, importManifest);
387
+ }
388
+ async function verifyThreeWayConsistency(input) {
389
+ const parentState = await input.deps.readParentState(input.parentRunDir);
390
+ const childState = await readDagRunState(input.activeChildDir);
391
+ const parentRecovery = parentState.frontendRecoveryState;
392
+ if (!parentRecovery || parentRecovery.childRunId !== input.childRunId) {
393
+ throw new Error("three-way consistency failure: parent childRunId mismatch");
394
+ }
395
+ if (childState.runId !== input.childRunId) {
396
+ throw new Error("three-way consistency failure: child state runId mismatch");
397
+ }
398
+ if (input.marker.childRunId !== input.childRunId) {
399
+ throw new Error("three-way consistency failure: marker childRunId mismatch");
400
+ }
401
+ if (input.marker.requestId !== input.requestId) {
402
+ throw new Error("three-way consistency failure: marker requestId mismatch");
403
+ }
404
+ if (input.marker.parentRunId !== input.parentRunId) {
405
+ throw new Error("three-way consistency failure: marker parentRunId mismatch");
406
+ }
407
+ if (input.marker.recoveryRootRunId !== input.recoveryRootRunId) {
408
+ throw new Error("three-way consistency failure: marker recoveryRootRunId mismatch");
409
+ }
410
+ const actualManifestSha256 = await hashFile(path.join(input.activeChildDir, FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH));
411
+ if (actualManifestSha256 !== input.marker.importManifestSha256) {
412
+ throw new Error("three-way consistency failure: import manifest sha256 mismatch");
413
+ }
414
+ }
415
+ /**
416
+ * Stage and commit one reserved child run for a parent frontend recovery
417
+ * intent. Single-writer: the recovery coordinator is the only caller.
418
+ * Fail-closed on revision drift, missing intent, unreadable prewrite result,
419
+ * hash mismatch, marker-write failure, or consistency failure.
420
+ */
421
+ export async function stageRecoveryChild(options) {
422
+ const { cwd, parentRunId } = options;
423
+ const now = options.now ?? new Date();
424
+ const createdAt = now.toISOString();
425
+ const deps = resolveDeps(options.dependencies);
426
+ const parentRunDir = getDagRunDir(cwd, "active", parentRunId);
427
+ const parentState = await deps.readParentState(parentRunDir);
428
+ const parentSpec = await readDagRunSpec(parentRunDir);
429
+ const recovery = parentState.frontendRecoveryState;
430
+ if (!recovery) {
431
+ throw new Error(`parent run ${parentRunId} has no frontend recovery intent`);
432
+ }
433
+ const requestId = recovery.requestId;
434
+ if (!requestId) {
435
+ throw new Error(`parent run ${parentRunId} recovery intent has no requestId`);
436
+ }
437
+ // Idempotent short-circuit (D4.2): reserved childRunId + activated marker +
438
+ // existing child dir means the request was already fully committed.
439
+ if (recovery.childRunId) {
440
+ const existingMarker = await readFrontendRecoveryMarker(parentRunDir, requestId);
441
+ if (existingMarker) {
442
+ const existingChildDir = getDagRunDir(cwd, "active", existingMarker.childRunId);
443
+ if (await dagRunDirExists(existingChildDir)) {
444
+ return {
445
+ ok: true,
446
+ childRunId: existingMarker.childRunId,
447
+ childRunDir: existingChildDir,
448
+ marker: existingMarker,
449
+ idempotentReplay: true,
450
+ };
451
+ }
452
+ }
453
+ }
454
+ // Derive the childRunId exactly once; replay always reuses a reserved id.
455
+ const childRunId = recovery.childRunId ??
456
+ (await (options.buildChildRunId ?? buildDagRunId)(parentSpec, cwd));
457
+ // Orphan staging cleanup (D4.3): remove any in-flight staging dir for this id.
458
+ const activeRootDir = getDagRunDir(cwd, "active", "");
459
+ const stagingDir = path.join(activeRootDir, `${FRONTEND_RECOVERY_STAGING_PREFIX}${childRunId}`);
460
+ await rm(stagingDir, { recursive: true, force: true });
461
+ // CAS #1: reserve childRunId (phase stays child-staging) before any child
462
+ // files exist, so a crash can never produce two children for one requestId.
463
+ if (!recovery.childRunId) {
464
+ await casUpdateFrontendRecoveryState(parentRunDir, recovery.revision, deps, (rec) => {
465
+ rec.childRunId = childRunId;
466
+ rec.phase = "child-staging";
467
+ });
468
+ }
469
+ const parentAfterReserve = await deps.readParentState(parentRunDir);
470
+ const reserved = parentAfterReserve.frontendRecoveryState;
471
+ if (!reserved || reserved.childRunId !== childRunId) {
472
+ throw new Error("frontend recovery reservation drift after CAS; refuse to continue");
473
+ }
474
+ const activeChildDir = getDagRunDir(cwd, "active", childRunId);
475
+ const childDirExists = await dagRunDirExists(activeChildDir);
476
+ let importManifestSha256;
477
+ if (childDirExists) {
478
+ // Crash between rename and marker write: the child is already fully
479
+ // materialized. Recover by rewriting the marker instead of rebuilding.
480
+ importManifestSha256 = await hashFile(path.join(activeChildDir, FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH));
481
+ }
482
+ else {
483
+ await materializeChildStaging({
484
+ cwd,
485
+ parentRunDir,
486
+ parentRunId,
487
+ parentSpec,
488
+ parentState,
489
+ recovery,
490
+ requestId,
491
+ childRunId,
492
+ stagingDir,
493
+ createdAt,
494
+ });
495
+ await transferDagRunDir(stagingDir, activeChildDir);
496
+ importManifestSha256 = await hashFile(path.join(activeChildDir, FRONTEND_RECOVERY_IMPORT_MANIFEST_REL_PATH));
497
+ }
498
+ const marker = {
499
+ schemaVersion: 1,
500
+ requestId,
501
+ recoveryRootRunId: recovery.recoveryRootRunId,
502
+ parentRunId,
503
+ childRunId,
504
+ importManifestSha256,
505
+ createdAt,
506
+ activatedAt: createdAt,
507
+ };
508
+ const markerPath = frontendRecoveryMarkerRelPath(parentRunDir, requestId);
509
+ try {
510
+ await deps.writeMarker(markerPath, marker);
511
+ await verifyThreeWayConsistency({
512
+ parentRunDir,
513
+ activeChildDir,
514
+ marker,
515
+ parentRunId,
516
+ requestId,
517
+ recoveryRootRunId: recovery.recoveryRootRunId,
518
+ childRunId,
519
+ deps,
520
+ });
521
+ await casUpdateFrontendRecoveryState(parentRunDir, reserved.revision, deps, (rec) => {
522
+ rec.phase = "child-running";
523
+ });
524
+ }
525
+ catch (error) {
526
+ // Best-effort cleanup so no marker-less active child survives. If cleanup
527
+ // fails, the unmarked child remains non-executable (AC-2).
528
+ await rm(activeChildDir, { recursive: true, force: true }).catch(() => { });
529
+ await rm(stagingDir, { recursive: true, force: true }).catch(() => { });
530
+ throw error;
531
+ }
532
+ return {
533
+ ok: true,
534
+ childRunId,
535
+ childRunDir: activeChildDir,
536
+ marker,
537
+ idempotentReplay: false,
538
+ };
539
+ }