@tea-agent/loop-agent 0.35.2 → 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 (45) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +31 -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 +11 -0
  10. package/dist/worker/console/chat/pi-runtime.js +6 -2
  11. package/dist/worker/console/chat/turn-process.js +17 -9
  12. package/dist/worker/console/chat/workspace-landing.js +1 -1
  13. package/dist/worker/console/static/assets/index-DuVLjCIT.js +57 -0
  14. package/dist/worker/console/static/index.html +1 -1
  15. package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -0
  16. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +15 -3
  17. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  18. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  19. package/dist/worker/console/static-src/operator-chat/useChatThread.js +1 -0
  20. package/dist/worker/loop-agent/loop-agent-client.js +17 -3
  21. package/dist/worker/observability/read-model.js +20 -0
  22. package/dist/worker/observe/spec-evidence.js +3 -8
  23. package/dist/worker/observe/static/views/dag-inspector.js +6 -71
  24. package/dist/worker/preflight.js +2 -1
  25. package/dist/workflows/dag/backend-test-scenario-param.js +33 -23
  26. package/dist/workflows/dag/contract-output-registry.js +14 -0
  27. package/dist/workflows/dag/contract-validator-registrations.js +8 -0
  28. package/dist/workflows/dag/dynamic-runtime/shared.js +9 -1
  29. package/dist/workflows/dag/frontend-implementation-contract.js +233 -39
  30. package/dist/workflows/dag/frontend-prewrite-gate.js +364 -61
  31. package/dist/workflows/dag/frontend-recovery-plan.js +73 -0
  32. package/dist/workflows/dag/frontend-recovery-root-manifest.js +123 -0
  33. package/dist/workflows/dag/frontend-recovery-run.js +539 -0
  34. package/dist/workflows/dag/frontend-repair.js +219 -18
  35. package/dist/workflows/dag/frontend-verification-trace.js +47 -32
  36. package/dist/workflows/dag/frontend-writer-recovery.js +106 -0
  37. package/dist/workflows/dag/frontend-writer-rollback.js +821 -0
  38. package/dist/workflows/dag/init-hybrid.js +49 -24
  39. package/dist/workflows/dag/node-execution.js +89 -0
  40. package/dist/workflows/dag/recovery-recommendation.js +58 -0
  41. package/dist/workflows/dag/runner.js +245 -11
  42. package/dist/workflows/dag/scheduler.js +257 -3
  43. package/dist/workflows/dag/types.js +130 -2
  44. package/package.json +4 -3
  45. package/dist/worker/console/static/assets/index-gVHrlqI9.js +0 -56
@@ -20,6 +20,14 @@ export const frontendRepairFailureClassSchema = z.enum([
20
20
  "spec-unclear",
21
21
  "unknown",
22
22
  ]);
23
+ export const frontendCommandCategorySchema = z.enum([
24
+ "typecheck",
25
+ "build",
26
+ "lint",
27
+ "component-test",
28
+ "unit-test",
29
+ "trace",
30
+ ]);
23
31
  const REPAIRABLE = new Set([
24
32
  "typecheck",
25
33
  "build",
@@ -27,12 +35,72 @@ const REPAIRABLE = new Set([
27
35
  "component-test",
28
36
  "unit-test",
29
37
  "trace",
30
- "review",
31
38
  ]);
32
39
  export function isFrontendRepairable(failureClass) {
33
40
  return REPAIRABLE.has(failureClass);
34
41
  }
42
+ /**
43
+ * Deterministic commandLabel → commandCategory mapping (AC-3).
44
+ * `targetType` comes from the contract verificationTargets[].type
45
+ * (static | unit | component | integration | mock); the literal commandLabel
46
+ * wins over type so a `static` typecheck target is never misread as a test.
47
+ */
48
+ export function mapCommandLabelToCategory(commandLabel, targetType) {
49
+ const label = commandLabel.toLowerCase();
50
+ if (/(?:typecheck|check-types|type-check|\btsc\b)/.test(label))
51
+ return "typecheck";
52
+ if (/(?:vite\s+build|next\s+build|webpack|rollup|\bbuild\b)/.test(label))
53
+ return "build";
54
+ if (/(?:eslint|\blint\b)/.test(label))
55
+ return "lint";
56
+ if (targetType === "component")
57
+ return "component-test";
58
+ if (targetType === "unit")
59
+ return "unit-test";
60
+ if (/(?:component|testing-library|render\()/.test(label))
61
+ return "component-test";
62
+ return "unit-test";
63
+ }
64
+ export function isReviewRepairable(finding, implementWriteSet) {
65
+ const hasFile = Boolean(finding.file && finding.file.trim().length > 0);
66
+ const hasLocation = Boolean((finding.line !== undefined && finding.line > 0) ||
67
+ (finding.symbol && finding.symbol.trim().length > 0));
68
+ const hasRequirement = Boolean((finding.requirementId && finding.requirementId.trim().length > 0) ||
69
+ (finding.acceptanceCriteriaId &&
70
+ finding.acceptanceCriteriaId.trim().length > 0));
71
+ const fixScope = finding.fixScope ?? (hasFile ? [finding.file] : []);
72
+ const fixScopeSubset = fixScope.length > 0 &&
73
+ fixScope.every((entry) => isPathWithinWriteSet(entry, implementWriteSet));
74
+ return (hasFile &&
75
+ hasLocation &&
76
+ hasRequirement &&
77
+ fixScopeSubset &&
78
+ finding.requiresContractReplan !== true);
79
+ }
35
80
  export function classifyFrontendFailure(input) {
81
+ // AC-2: structured evidence takes priority over string-blob heuristics.
82
+ if (input.failureCategory === "write-guard") {
83
+ return "path";
84
+ }
85
+ if (input.traceTarget || input.commandCategory === "trace") {
86
+ return "trace";
87
+ }
88
+ if (input.commandCategory) {
89
+ switch (input.commandCategory) {
90
+ case "typecheck":
91
+ return "typecheck";
92
+ case "build":
93
+ return "build";
94
+ case "lint":
95
+ return "lint";
96
+ case "component-test":
97
+ return "component-test";
98
+ case "unit-test":
99
+ return "unit-test";
100
+ }
101
+ }
102
+ // Fallback: string-blob heuristic, preserved in its original branch order so
103
+ // legacy assertions (error TS2304 → typecheck, write-guard → path, etc.) stay green.
36
104
  const blob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stdout ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
37
105
  if (input.nodeId.includes("contract") ||
38
106
  blob.includes("contract mismatch") ||
@@ -120,6 +188,23 @@ export const frontendRepairAssessmentSchema = z
120
188
  allowedRepairWriteSet: z.array(z.string().min(1)),
121
189
  evidenceRefs: z.array(z.string().min(1)),
122
190
  browserStatus: z.literal("not-run"),
191
+ // AC-5 structured evidence fields (all optional so existing minimal
192
+ // fixtures and frontend-review-context safeParse stay compatible).
193
+ verificationTargetId: z.string().min(1).optional(),
194
+ commandLabel: z.string().min(1).optional(),
195
+ exitCode: z.number().int().optional(),
196
+ commandCategory: frontendCommandCategorySchema.optional(),
197
+ requirementIds: z.array(z.string().min(1)).optional(),
198
+ acceptanceCriteriaIds: z.array(z.string().min(1)).optional(),
199
+ fixScope: z.array(z.string().min(1)).optional(),
200
+ changedPaths: z.array(z.string().min(1)).optional(),
201
+ traceRef: z
202
+ .object({
203
+ relativePath: z.string().min(1),
204
+ schemaId: z.string().min(1),
205
+ })
206
+ .strict()
207
+ .optional(),
123
208
  })
124
209
  .strict();
125
210
  function nodeHadFailure(record) {
@@ -135,19 +220,24 @@ function nodeHadFailure(record) {
135
220
  function normalize(value) {
136
221
  return value.replace(/\\/g, "/").replace(/^\.\//, "");
137
222
  }
223
+ /**
224
+ * Whether a concrete path or glob entry is contained by the implement writeSet.
225
+ * Exact glob equality is the norm (repair writeSet === implement writeSet);
226
+ * concrete (non-glob) paths may also be covered by an implement glob.
227
+ */
228
+ export function isPathWithinWriteSet(entry, implementWriteSet) {
229
+ const normalizedEntry = normalize(entry);
230
+ const normalizedImplement = implementWriteSet.map(normalize);
231
+ if (normalizedImplement.includes(normalizedEntry))
232
+ return true;
233
+ if (!normalizedEntry.includes("*")) {
234
+ return implementWriteSet.some((pattern) => pathMatchesPattern(normalizedEntry, pattern));
235
+ }
236
+ return false;
237
+ }
138
238
  export function assertWriteSetSubset(repairWriteSet, implementWriteSet) {
139
239
  for (const entry of repairWriteSet) {
140
- const ok = implementWriteSet.some((pattern) => normalize(entry) === normalize(pattern) ||
141
- pathMatchesPattern(normalize(entry), pattern) ||
142
- pathMatchesPattern(normalize(pattern), entry));
143
- // exact equality preferred for generation-time copy
144
- if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
145
- // allow identical globs only
146
- if (!ok || normalize(entry) !== normalize(entry)) {
147
- // require exact membership for exclusive writeSet entries
148
- }
149
- }
150
- if (!implementWriteSet.map(normalize).includes(normalize(entry))) {
240
+ if (!isPathWithinWriteSet(entry, implementWriteSet)) {
151
241
  throw new Error(`repair writeSet entry "${entry}" is not ⊆ implement writeSet`);
152
242
  }
153
243
  }
@@ -170,6 +260,63 @@ async function loadImplementWriteSet(runDir, fallback) {
170
260
  }
171
261
  return fallback;
172
262
  }
263
+ function matchVerificationTarget(contract, commandLabel, command) {
264
+ for (const target of contract.verificationTargets) {
265
+ if (commandLabel && target.commandLabel === commandLabel)
266
+ return target;
267
+ if (command &&
268
+ (command.includes(target.commandLabel) ||
269
+ target.commandLabel.includes(command))) {
270
+ return target;
271
+ }
272
+ }
273
+ return undefined;
274
+ }
275
+ function deriveFailureEvidence(input) {
276
+ const evidence = {};
277
+ for (const { record } of input.failed) {
278
+ const results = record.commandResults ?? [];
279
+ const failedResult = results.find((item) => item.ok === false) ?? results[0];
280
+ if (failedResult &&
281
+ evidence.exitCode === undefined &&
282
+ typeof failedResult.exitCode === "number") {
283
+ evidence.exitCode = failedResult.exitCode;
284
+ }
285
+ if (failedResult?.commandLabel && !evidence.commandLabel) {
286
+ evidence.commandLabel = failedResult.commandLabel;
287
+ }
288
+ // Structured trace facts passed by the verification bundle / trace gate.
289
+ const failedTraceTarget = (record.traceTargets ?? []).find((target) => target.status === "failed");
290
+ if (failedTraceTarget && !evidence.verificationTargetId) {
291
+ evidence.verificationTargetId = failedTraceTarget.id;
292
+ if (!evidence.commandLabel)
293
+ evidence.commandLabel = failedTraceTarget.commandLabel;
294
+ evidence.traceTarget = failedTraceTarget;
295
+ evidence.traceRef = {
296
+ relativePath: "contracts/frontend-verification-trace.json",
297
+ schemaId: "frontend-verification-trace-v1",
298
+ };
299
+ }
300
+ if (record.changedPaths?.length && !evidence.changedPaths) {
301
+ evidence.changedPaths = [...record.changedPaths];
302
+ }
303
+ }
304
+ // Resolve commandLabel → contract verification target → category + scope.
305
+ const target = matchVerificationTarget(input.contract, evidence.commandLabel);
306
+ if (target) {
307
+ if (!evidence.verificationTargetId)
308
+ evidence.verificationTargetId = target.id;
309
+ if (!evidence.commandLabel)
310
+ evidence.commandLabel = target.commandLabel;
311
+ evidence.requirementIds ??= [...target.requirementIds];
312
+ evidence.acceptanceCriteriaIds ??= [...target.requirementIds];
313
+ evidence.fixScope ??= [target.file];
314
+ }
315
+ if (evidence.commandLabel) {
316
+ evidence.commandCategory ??= mapCommandLabelToCategory(evidence.commandLabel, target?.type);
317
+ }
318
+ return evidence;
319
+ }
173
320
  export async function runFrontendFailureAssessGate(input) {
174
321
  const attempt = input.attempt ?? 1;
175
322
  const implementWriteSet = await loadImplementWriteSet(input.runDir, input.implementWriteSet ?? []);
@@ -179,12 +326,14 @@ export async function runFrontendFailureAssessGate(input) {
179
326
  const contractRel = "contracts/frontend-implementation-contract.json";
180
327
  const contractPath = path.join(input.runDir, contractRel);
181
328
  let contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
329
+ let contract;
182
330
  try {
183
331
  const raw = JSON.parse(await readFile(contractPath, "utf8"));
184
332
  const parsed = frontendImplementationContractSchema.safeParse(raw);
185
333
  if (!parsed.success) {
186
334
  throw new Error("invalid frontend implementation contract");
187
335
  }
336
+ contract = parsed.data;
188
337
  contractSchemaId = FRONTEND_IMPLEMENTATION_CONTRACT_SCHEMA_ID;
189
338
  }
190
339
  catch {
@@ -218,7 +367,6 @@ export async function runFrontendFailureAssessGate(input) {
218
367
  // missing optional nodes ok
219
368
  }
220
369
  }
221
- assertWriteSetSubset(implementWriteSet, implementWriteSet);
222
370
  if (failed.length === 0) {
223
371
  const assessment = {
224
372
  schemaVersion: 1,
@@ -241,16 +389,49 @@ export async function runFrontendFailureAssessGate(input) {
241
389
  return assessment;
242
390
  }
243
391
  const primary = failed[0];
392
+ const evidence = deriveFailureEvidence({ contract, failed });
244
393
  const failureClass = classifyFrontendFailure({
245
394
  nodeId: primary.nodeId,
246
395
  failureCategory: primary.record.failureCategory,
247
396
  stdout: primary.record.stdout,
248
397
  stderr: primary.record.stderr,
398
+ commandLabel: evidence.commandLabel,
399
+ commandCategory: evidence.commandCategory,
400
+ exitCode: evidence.exitCode,
401
+ traceTarget: evidence.traceTarget ?? null,
402
+ changedPaths: evidence.changedPaths,
249
403
  });
250
- const eligible = isFrontendRepairable(failureClass) && attempt <= 1;
251
- const reason = eligible
252
- ? `repairable failureClass=${failureClass} on ${primary.nodeId}`
253
- : `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`;
404
+ // AC-5: repairable=true requires structured evidence (a resolved exitCode,
405
+ // commandCategory, or trace-bound verification target). review has its own
406
+ // stricter gate (AC-4) and is never unconditionally repairable.
407
+ const baseEligible = isFrontendRepairable(failureClass) && attempt <= 1;
408
+ let eligible;
409
+ let reason;
410
+ if (failureClass === "review") {
411
+ const finding = primary.record.reviewFinding ?? {};
412
+ eligible = attempt <= 1 && isReviewRepairable(finding, implementWriteSet);
413
+ reason = eligible
414
+ ? `repairable review finding on ${primary.nodeId}`
415
+ : `review finding lacks file/line-symbol/requirement evidence or is outside writeSet on ${primary.nodeId}`;
416
+ }
417
+ else {
418
+ const hasStructuredEvidence = evidence.exitCode !== undefined ||
419
+ evidence.commandCategory !== undefined ||
420
+ Boolean(evidence.verificationTargetId);
421
+ eligible = baseEligible && hasStructuredEvidence;
422
+ reason = eligible
423
+ ? `repairable failureClass=${failureClass} on ${primary.nodeId}`
424
+ : !baseEligible
425
+ ? `non-repairable or ineligible: failureClass=${failureClass} attempt=${attempt}`
426
+ : `missing structured evidence for failureClass=${failureClass} on ${primary.nodeId}`;
427
+ }
428
+ // fixScope and changedPaths must stay inside the implement writeSet.
429
+ if (evidence.fixScope?.length) {
430
+ assertWriteSetSubset(evidence.fixScope, implementWriteSet);
431
+ }
432
+ if (evidence.changedPaths?.length) {
433
+ assertWriteSetSubset(evidence.changedPaths, implementWriteSet);
434
+ }
254
435
  const assessment = {
255
436
  schemaVersion: 1,
256
437
  schemaId: FRONTEND_REPAIR_ASSESSMENT_SCHEMA_ID,
@@ -267,6 +448,25 @@ export async function runFrontendFailureAssessGate(input) {
267
448
  allowedRepairWriteSet: [...implementWriteSet],
268
449
  evidenceRefs: [contractRel, ...failed.map((item) => `${item.nodeId}.json`)],
269
450
  browserStatus: "not-run",
451
+ ...(evidence.verificationTargetId
452
+ ? { verificationTargetId: evidence.verificationTargetId }
453
+ : {}),
454
+ ...(evidence.commandLabel ? { commandLabel: evidence.commandLabel } : {}),
455
+ ...(evidence.exitCode !== undefined ? { exitCode: evidence.exitCode } : {}),
456
+ ...(evidence.commandCategory
457
+ ? { commandCategory: evidence.commandCategory }
458
+ : {}),
459
+ ...(evidence.requirementIds?.length
460
+ ? { requirementIds: evidence.requirementIds }
461
+ : {}),
462
+ ...(evidence.acceptanceCriteriaIds?.length
463
+ ? { acceptanceCriteriaIds: evidence.acceptanceCriteriaIds }
464
+ : {}),
465
+ ...(evidence.fixScope?.length ? { fixScope: evidence.fixScope } : {}),
466
+ ...(evidence.changedPaths?.length
467
+ ? { changedPaths: evidence.changedPaths }
468
+ : {}),
469
+ ...(evidence.traceRef ? { traceRef: evidence.traceRef } : {}),
270
470
  };
271
471
  await writeAssessment(input.runDir, assessment);
272
472
  return assessment;
@@ -322,7 +522,8 @@ export async function runFrontendRepairContractGate(input) {
322
522
  if (!assessment.eligible) {
323
523
  throw new Error(`repair-contract: non-repairable failure (${assessment.failureClass}): ${assessment.reason}`);
324
524
  }
325
- if (!isFrontendRepairable(assessment.failureClass)) {
525
+ if (!isFrontendRepairable(assessment.failureClass) &&
526
+ assessment.failureClass !== "review") {
326
527
  throw new Error(`repair-contract: failureClass ${assessment.failureClass} is not repairable`);
327
528
  }
328
529
  return { ok: true, assessment };
@@ -40,6 +40,48 @@ function symbolEvidenceCandidates(symbol) {
40
40
  function isConfigurationVerificationFile(file) {
41
41
  return /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|(?:vite|webpack|rollup|docusaurus)\.config\.[cm]?[jt]s)$/.test(file.replace(/\\/g, "/"));
42
42
  }
43
+ /**
44
+ * Pure verification-target evaluation shared by the trace gate and the repair
45
+ * assess gate. It derives, for each contract verification target, whether the
46
+ * target bound to frozen evidence (commandLabel owners), and whether the file
47
+ * and optional symbol exist in the workspace. It performs no writes and throws
48
+ * nothing: failures are returned as `hardIssues` plus per-target `status`.
49
+ */
50
+ export async function evaluateVerificationTargets(input) {
51
+ const targets = [];
52
+ const hardIssues = [];
53
+ for (const target of input.contract.verificationTargets) {
54
+ const issues = [];
55
+ const owners = input.labelOwners.get(target.commandLabel);
56
+ if (!owners?.length) {
57
+ issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
58
+ }
59
+ const fileIssues = await assertFileAndSymbol({
60
+ workspaceRoot: input.workspaceRoot,
61
+ file: target.file,
62
+ // Configuration files prove that the command's entrypoint exists;
63
+ // they do not expose source symbols. LLMs sometimes derive a
64
+ // filename fragment such as "build" from tsconfig.build.json.
65
+ symbol: isConfigurationVerificationFile(target.file)
66
+ ? undefined
67
+ : target.symbol,
68
+ });
69
+ issues.push(...fileIssues);
70
+ const status = issues.length ? "failed" : "ok";
71
+ if (issues.length)
72
+ hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
73
+ targets.push({
74
+ id: target.id,
75
+ commandLabel: target.commandLabel,
76
+ file: target.file,
77
+ symbol: target.symbol,
78
+ status,
79
+ matchedNodeIds: owners ?? [],
80
+ issues,
81
+ });
82
+ }
83
+ return { targets, hardIssues };
84
+ }
43
85
  async function assertFileAndSymbol(input) {
44
86
  const issues = [];
45
87
  const absolute = path.resolve(input.workspaceRoot, input.file);
@@ -175,38 +217,11 @@ export async function runFrontendVerificationTraceGate(input) {
175
217
  }
176
218
  }
177
219
  }
178
- const targets = [];
179
- const hardIssues = [];
180
- for (const target of contract.verificationTargets) {
181
- const issues = [];
182
- const owners = labelOwners.get(target.commandLabel);
183
- if (!owners?.length) {
184
- issues.push(`commandLabel not executed in current-run frozen evidence: ${target.commandLabel}`);
185
- }
186
- const fileIssues = await assertFileAndSymbol({
187
- workspaceRoot: input.workspaceRoot,
188
- file: target.file,
189
- // Configuration files prove that the command's entrypoint exists;
190
- // they do not expose source symbols. LLMs sometimes derive a
191
- // filename fragment such as "build" from tsconfig.build.json.
192
- symbol: isConfigurationVerificationFile(target.file)
193
- ? undefined
194
- : target.symbol,
195
- });
196
- issues.push(...fileIssues);
197
- const status = issues.length ? "failed" : "ok";
198
- if (issues.length)
199
- hardIssues.push(...issues.map((i) => `${target.id}: ${i}`));
200
- targets.push({
201
- id: target.id,
202
- commandLabel: target.commandLabel,
203
- file: target.file,
204
- symbol: target.symbol,
205
- status,
206
- matchedNodeIds: owners ?? [],
207
- issues,
208
- });
209
- }
220
+ const { targets, hardIssues } = await evaluateVerificationTargets({
221
+ contract,
222
+ labelOwners,
223
+ workspaceRoot: input.workspaceRoot,
224
+ });
210
225
  if (hardIssues.length) {
211
226
  throw new Error(`trace: ${hardIssues.join("; ")}`);
212
227
  }
@@ -0,0 +1,106 @@
1
+ import path from "node:path";
2
+ import { readFile } from "node:fs/promises";
3
+ import { getDagRunDir } from "./lifecycle.js";
4
+ import { captureWriterBaseline, readJournalFile, restoreJournal, sha256HexBytes, writeJournalFile, } from "./frontend-writer-rollback.js";
5
+ /**
6
+ * Phase 5: writer transient partial-write recovery — compose the phase-4 rollback
7
+ * journal with the phase-3 staged child handoff. A frontend writer attempt is
8
+ * snapshotted before the provider call so a transient partial write can be rolled
9
+ * back to the pre-attempt baseline, then recovered by a staged child that re-runs
10
+ * `frontend-implement-pi`.
11
+ *
12
+ * The runner remains the single-writer coordinator: it captures the baseline via
13
+ * {@link captureFrontendWriterAttemptIntent} before the writer provider call, and
14
+ * on a transient partial write runs {@link rollbackFrontendWriter} before writing
15
+ * the recovery intent and staging the child.
16
+ */
17
+ /** Rollback intent path inside a run dir (written before the writer provider call). */
18
+ export const FRONTEND_WRITER_ROLLBACK_INTENT_REL = ".runtime/frontend-writer-rollback.json";
19
+ export function frontendWriterRollbackIntentAbsPath(runDir) {
20
+ return path.join(runDir, FRONTEND_WRITER_ROLLBACK_INTENT_REL);
21
+ }
22
+ /**
23
+ * AC/P1-9: capture the writeSet baseline and persist the attempt intent BEFORE
24
+ * the writer provider call. Returns the intent path. Callers must fail the
25
+ * attempt closed if this throws (no baseline → no rollback → no recovery).
26
+ */
27
+ export async function captureFrontendWriterAttemptIntent(input) {
28
+ const baseline = await captureWriterBaseline(input.cwd, input.writeSet);
29
+ const intentPath = frontendWriterRollbackIntentAbsPath(input.runDir);
30
+ await writeJournalFile(intentPath, baseline);
31
+ return intentPath;
32
+ }
33
+ /**
34
+ * Record the writer attempt's changed paths and per-path attempt hashes into the
35
+ * rollback intent AFTER the provider call. This is what makes restoreJournal able
36
+ * to CAS-restore: it compares each changed path's current hash against the
37
+ * recorded attempt hash before writing back the baseline.
38
+ */
39
+ export async function recordFrontendWriterAttempt(input) {
40
+ const intentPath = frontendWriterRollbackIntentAbsPath(input.runDir);
41
+ const snapshot = await readJournalFile(intentPath);
42
+ if (!snapshot)
43
+ return; // no baseline was captured; nothing to record
44
+ const changed = [...new Set(input.changedPaths)].sort((a, b) => a.localeCompare(b));
45
+ const fileHashes = {};
46
+ for (const rel of changed) {
47
+ try {
48
+ const bytes = await readFile(path.join(input.cwd, rel));
49
+ fileHashes[rel] = sha256HexBytes(bytes);
50
+ }
51
+ catch {
52
+ fileHashes[rel] = "absent";
53
+ }
54
+ }
55
+ snapshot.attemptChangedPaths = changed;
56
+ snapshot.attemptFileHashes = fileHashes;
57
+ await writeJournalFile(intentPath, snapshot);
58
+ }
59
+ /**
60
+ * Restore the parent workspace to its pre-attempt baseline using the phase-4 CAS
61
+ * journal. This must run BEFORE the recovery intent is written and the child is
62
+ * staged (rollback commit point): a blocked rollback short-circuits to
63
+ * `auto-recovery-blocked` and never stages a child.
64
+ */
65
+ export async function rollbackFrontendWriter(input) {
66
+ const parentRunDir = getDagRunDir(input.cwd, "active", input.parentRunId);
67
+ const snapshot = await readJournalFile(frontendWriterRollbackIntentAbsPath(parentRunDir));
68
+ if (!snapshot) {
69
+ return {
70
+ ok: false,
71
+ state: "missing-intent",
72
+ reason: "auto-recovery-blocked: writer rollback intent missing (baseline was not captured before the provider call)",
73
+ };
74
+ }
75
+ const result = await restoreJournal(snapshot, { repoRoot: input.cwd });
76
+ if (result.state !== "completed") {
77
+ return {
78
+ ok: false,
79
+ state: "blocked",
80
+ reason: result.blockedReason ?? "auto-recovery-blocked",
81
+ };
82
+ }
83
+ return {
84
+ ok: true,
85
+ state: "completed",
86
+ restoredPaths: result.restoredPaths,
87
+ snapshot,
88
+ };
89
+ }
90
+ /**
91
+ * Transient failure categories that indicate a writer may have partially written
92
+ * before the attempt was interrupted. Clean-timeout (zero writes) is handled by
93
+ * the retry policy and never reaches terminal aggregation as a recovery trigger.
94
+ */
95
+ const TRANSIENT_WRITER_FAILURE_CATEGORIES = new Set([
96
+ "timeout",
97
+ "network",
98
+ "rate-limit",
99
+ "unavailable",
100
+ "terminated",
101
+ "disconnect",
102
+ "provider-error",
103
+ ]);
104
+ export function isFrontendWriterTransientPartialWrite(input) {
105
+ return TRANSIENT_WRITER_FAILURE_CATEGORIES.has(input.failureCategory ?? "");
106
+ }