@tea-agent/loop-agent 0.20.1-beta.0 → 0.20.1

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 (50) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/application/dag/args.js +29 -0
  3. package/dist/application/dag/run-dag.js +3 -1
  4. package/dist/cli/command-definitions.js +15 -1
  5. package/dist/cli/program.js +11 -1
  6. package/dist/commands/dag-rerun-task.js +19 -0
  7. package/dist/commands/dag-rerun.js +111 -0
  8. package/dist/shared/operator/capabilities.js +54 -0
  9. package/dist/worker/console/index.js +1 -1
  10. package/dist/worker/console/inspect-split.js +82 -0
  11. package/dist/worker/console/operation-runner.js +3 -1
  12. package/dist/worker/console/operation-store.js +1 -0
  13. package/dist/worker/console/operator-actions.js +153 -2
  14. package/dist/worker/console/operator-user-error.js +10 -0
  15. package/dist/worker/console/pi-readiness.js +4 -0
  16. package/dist/worker/console/recovery-cta.js +116 -5
  17. package/dist/worker/console/recovery-selection.js +107 -0
  18. package/dist/worker/console/resolve-dag-run-for-task.js +50 -11
  19. package/dist/worker/console/routes.js +20 -0
  20. package/dist/worker/console/sibling-controller.js +12 -7
  21. package/dist/worker/console/static/assets/index-CUDke82y.js +18 -0
  22. package/dist/worker/console/static/assets/index-wSEksVSO.css +1 -0
  23. package/dist/worker/console/static/index.html +2 -2
  24. package/dist/worker/observability/read-model.js +60 -0
  25. package/dist/worker/observe/static/index.html +1 -1
  26. package/dist/worker/run-task/run-task.js +7 -0
  27. package/dist/workflows/dag/frontend-test-result-contract.js +64 -0
  28. package/dist/workflows/dag/init-hybrid.js +90 -48
  29. package/dist/workflows/dag/node-execution.js +40 -6
  30. package/dist/workflows/dag/output-protocol.js +76 -0
  31. package/dist/workflows/dag/rerun-plan.js +611 -0
  32. package/dist/workflows/dag/rerun-run.js +497 -0
  33. package/dist/workflows/dag/rerun-task.js +284 -0
  34. package/dist/workflows/dag/retry-policy.js +20 -1
  35. package/dist/workflows/dag/runner.js +50 -0
  36. package/dist/workflows/dag/skill-snapshot.js +22 -3
  37. package/dist/workflows/dag/types.js +7 -0
  38. package/dist/workflows/dag/validate.js +11 -0
  39. package/dist/workflows/dag/workspace-checkpoint.js +163 -0
  40. package/docs/README.md +1 -0
  41. package/docs/templates/agent-dag.schema.json +17 -2
  42. package/docs/templates/frontend-test-case-checklist.md +16 -1
  43. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +26 -2
  44. package/docs/templates/frontend-test-dag.json +65 -6
  45. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +4 -1
  46. package/package.json +1 -1
  47. package/skills/loop-agent/references/command-reference.md +3 -0
  48. package/skills/playwright-cli-case-generator/SKILL.md +35 -7
  49. package/dist/worker/console/static/assets/index-3vsjZJHq.js +0 -16
  50. package/dist/worker/console/static/assets/index-i1wV4LrY.css +0 -1
@@ -0,0 +1,284 @@
1
+ import { access, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { generateTaskDagUseCase } from "../../application/dag/generate-task-dag.js";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { DAG_RUNS_DIR, isTerminalDagRunStatus, locateDagRun, readDagRunSpec, readDagRunState, } from "./lifecycle.js";
6
+ export const RERUN_TASK_LINEAGE_REL_PATH = ".runtime/rerun-task-lineage.json";
7
+ export function parseDagRerunTaskArgs(args) {
8
+ if (args.length === 0) {
9
+ throw new Error("usage: dag rerun-task --run-id <id> --reason <text> --request-id <key> [--profile auto|minimal|standard|reviewed|supervised] [--task-id <id>] [--json]");
10
+ }
11
+ let parentRunId;
12
+ let reason;
13
+ let requestId;
14
+ let profile = "auto";
15
+ let profileExplicit = false;
16
+ let taskIdOverride;
17
+ let execute = true;
18
+ for (let index = 0; index < args.length; index += 1) {
19
+ const arg = args[index];
20
+ if (arg === "--run-id")
21
+ parentRunId = args[++index];
22
+ else if (arg.startsWith("--run-id="))
23
+ parentRunId = arg.slice("--run-id=".length);
24
+ else if (arg === "--reason")
25
+ reason = args[++index];
26
+ else if (arg.startsWith("--reason="))
27
+ reason = arg.slice("--reason=".length);
28
+ else if (arg === "--request-id")
29
+ requestId = args[++index];
30
+ else if (arg.startsWith("--request-id="))
31
+ requestId = arg.slice("--request-id=".length);
32
+ else if (arg === "--profile") {
33
+ profile = parseDagRerunTaskProfile(args[++index]);
34
+ profileExplicit = true;
35
+ }
36
+ else if (arg.startsWith("--profile=")) {
37
+ profile = parseDagRerunTaskProfile(arg.slice("--profile=".length));
38
+ profileExplicit = true;
39
+ }
40
+ else if (arg === "--task-id")
41
+ taskIdOverride = args[++index];
42
+ else if (arg.startsWith("--task-id="))
43
+ taskIdOverride = arg.slice("--task-id=".length);
44
+ else if (arg === "--json")
45
+ continue;
46
+ else if (arg === "--no-execute")
47
+ execute = false;
48
+ else if (arg.startsWith("-"))
49
+ throw new Error(`unknown dag rerun-task flag: ${arg}`);
50
+ else
51
+ throw new Error(`unexpected positional argument: ${arg}`);
52
+ }
53
+ if (!parentRunId) {
54
+ throw new Error("dag rerun-task requires --run-id <id>");
55
+ }
56
+ if (!reason?.trim()) {
57
+ throw new Error("dag rerun-task requires --reason <text>");
58
+ }
59
+ if (!requestId?.trim()) {
60
+ throw new Error("dag rerun-task requires --request-id <key>");
61
+ }
62
+ return {
63
+ parentRunId,
64
+ reason: reason.trim(),
65
+ requestId: requestId.trim(),
66
+ profile,
67
+ profileExplicit,
68
+ ...(taskIdOverride?.trim() ? { taskIdOverride: taskIdOverride.trim() } : {}),
69
+ execute,
70
+ };
71
+ }
72
+ function parseDagRerunTaskProfile(value) {
73
+ if (value === "auto" ||
74
+ value === "minimal" ||
75
+ value === "standard" ||
76
+ value === "reviewed" ||
77
+ value === "supervised") {
78
+ return value;
79
+ }
80
+ throw new Error(`dag rerun-task --profile must be one of: auto, minimal, standard, reviewed, supervised; got ${value ?? ""}`);
81
+ }
82
+ function assessStandaloneTaskRerunEligibility(input) {
83
+ if (input.lifecycle === "paused") {
84
+ return {
85
+ eligible: false,
86
+ blockedReason: "parent-run-paused-use-resume",
87
+ };
88
+ }
89
+ if (input.lifecycle === "active" &&
90
+ !isTerminalDagRunStatus(input.state.status)) {
91
+ return {
92
+ eligible: false,
93
+ blockedReason: "parent-run-still-active",
94
+ };
95
+ }
96
+ if (!isTerminalDagRunStatus(input.state.status)) {
97
+ return {
98
+ eligible: false,
99
+ blockedReason: "parent-run-not-terminal",
100
+ };
101
+ }
102
+ if (input.state.status === "superseded" || input.state.status === "abandoned") {
103
+ return {
104
+ eligible: false,
105
+ blockedReason: `parent-run-${input.state.status}`,
106
+ };
107
+ }
108
+ if (input.state.status === "finished") {
109
+ return {
110
+ eligible: true,
111
+ note: "parent-run-succeeded-full-rerun-still-allowed",
112
+ };
113
+ }
114
+ return { eligible: true };
115
+ }
116
+ function resolveTaskIdForRerun(spec, taskIdOverride) {
117
+ if (taskIdOverride?.trim()) {
118
+ return { taskId: taskIdOverride.trim() };
119
+ }
120
+ const fromContract = spec.taskContractBinding?.taskId;
121
+ const fromSource = spec.sourceBinding?.taskId;
122
+ if (fromContract && fromSource && fromContract !== fromSource) {
123
+ return { blockedReason: "ambiguous-task-identity" };
124
+ }
125
+ const taskId = fromContract ?? fromSource;
126
+ if (!taskId) {
127
+ return {
128
+ blockedReason: "missing-task-identity-requires-task-id-override",
129
+ };
130
+ }
131
+ return { taskId };
132
+ }
133
+ function operatorRequestPath(cwd, requestId) {
134
+ return path.join(cwd, DAG_RUNS_DIR, ".operator-requests", `${requestId}.json`);
135
+ }
136
+ async function fileExists(filePath) {
137
+ try {
138
+ await access(filePath);
139
+ return true;
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ async function readExistingOperatorRequest(cwd, requestId) {
146
+ try {
147
+ const raw = JSON.parse(await readFile(operatorRequestPath(cwd, requestId), "utf-8"));
148
+ if (raw.result && typeof raw.result === "object") {
149
+ return { ...raw.result, idempotentReplay: true };
150
+ }
151
+ }
152
+ catch {
153
+ // not found or unreadable
154
+ }
155
+ return undefined;
156
+ }
157
+ async function persistOperatorRequest(cwd, result) {
158
+ await writeJsonAtomic(operatorRequestPath(cwd, result.requestId), {
159
+ schemaVersion: 1,
160
+ kind: "standalone-task-rerun-request",
161
+ parentRunId: result.parentRunId,
162
+ newRunId: result.newRunId,
163
+ taskId: result.taskId,
164
+ reason: result.reason,
165
+ requestId: result.requestId,
166
+ createdAt: new Date().toISOString(),
167
+ result,
168
+ }, { repoRoot: cwd });
169
+ }
170
+ async function writeRerunTaskLineage(cwd, newRunId, input) {
171
+ const located = await locateDagRun(cwd, newRunId);
172
+ if (!located)
173
+ return;
174
+ await writeJsonAtomic(path.join(located.runDir, RERUN_TASK_LINEAGE_REL_PATH), {
175
+ schemaVersion: 1,
176
+ kind: "standalone-task-rerun",
177
+ parentRunId: input.parentRunId,
178
+ reason: input.reason,
179
+ requestId: input.requestId,
180
+ createdAt: new Date().toISOString(),
181
+ }, { repoRoot: cwd, allowCompletedFactsWrite: true });
182
+ }
183
+ function blockedResult(input) {
184
+ return {
185
+ ok: false,
186
+ parentRunId: input.parentRunId,
187
+ reason: input.reason,
188
+ requestId: input.requestId,
189
+ mode: "blocked",
190
+ blockedReason: input.blockedReason,
191
+ ...(input.note ? { note: input.note } : {}),
192
+ ...(input.taskId ? { taskId: input.taskId } : {}),
193
+ };
194
+ }
195
+ function extractRunSummary(generateResult) {
196
+ if (generateResult.mode === "generate+validate")
197
+ return undefined;
198
+ return generateResult.run;
199
+ }
200
+ function extractNewRunId(generateResult) {
201
+ const runSummary = extractRunSummary(generateResult);
202
+ return runSummary?.runId;
203
+ }
204
+ export async function rerunStandaloneTask(input) {
205
+ const existing = await readExistingOperatorRequest(input.cwd, input.requestId);
206
+ if (existing) {
207
+ return existing;
208
+ }
209
+ const located = await locateDagRun(input.cwd, input.parentRunId);
210
+ if (!located) {
211
+ throw new Error(`dag run not found: ${input.parentRunId}`);
212
+ }
213
+ const state = await readDagRunState(located.runDir);
214
+ const eligibility = assessStandaloneTaskRerunEligibility({
215
+ lifecycle: located.lifecycle,
216
+ state,
217
+ });
218
+ if (!eligibility.eligible) {
219
+ return blockedResult({
220
+ parentRunId: input.parentRunId,
221
+ reason: input.reason,
222
+ requestId: input.requestId,
223
+ blockedReason: eligibility.blockedReason,
224
+ note: eligibility.note,
225
+ });
226
+ }
227
+ const spec = await readDagRunSpec(located.runDir);
228
+ const taskResolution = resolveTaskIdForRerun(spec, input.taskIdOverride);
229
+ if (!taskResolution.taskId) {
230
+ return blockedResult({
231
+ parentRunId: input.parentRunId,
232
+ reason: input.reason,
233
+ requestId: input.requestId,
234
+ blockedReason: taskResolution.blockedReason,
235
+ note: eligibility.note,
236
+ });
237
+ }
238
+ const execute = input.execute ?? true;
239
+ const generateResult = await generateTaskDagUseCase({
240
+ repoRoot: input.cwd,
241
+ taskId: taskResolution.taskId,
242
+ cwd: input.cwd,
243
+ strictModels: true,
244
+ execute,
245
+ initOnly: false,
246
+ dryRun: false,
247
+ profile: input.profile ?? "auto",
248
+ profileExplicit: input.profileExplicit ?? false,
249
+ });
250
+ const newRunId = extractNewRunId(generateResult);
251
+ const runSummary = extractRunSummary(generateResult);
252
+ const completedSuccessfully = !execute ||
253
+ (runSummary !== undefined && "status" in runSummary && runSummary.status === "finished");
254
+ if (execute && newRunId) {
255
+ await writeRerunTaskLineage(input.cwd, newRunId, {
256
+ parentRunId: input.parentRunId,
257
+ reason: input.reason,
258
+ requestId: input.requestId,
259
+ });
260
+ }
261
+ const result = {
262
+ ok: completedSuccessfully,
263
+ parentRunId: input.parentRunId,
264
+ reason: input.reason,
265
+ requestId: input.requestId,
266
+ mode: "generate+validate+execute",
267
+ taskId: taskResolution.taskId,
268
+ ...(newRunId ? { newRunId } : {}),
269
+ ...(runSummary && !completedSuccessfully && "status" in runSummary
270
+ ? { note: `child-run-${runSummary.status}` }
271
+ : eligibility.note
272
+ ? { note: eligibility.note }
273
+ : {}),
274
+ generateResult,
275
+ ...(runSummary ? { runSummary } : {}),
276
+ };
277
+ if (await fileExists(operatorRequestPath(input.cwd, input.requestId))) {
278
+ const replay = await readExistingOperatorRequest(input.cwd, input.requestId);
279
+ if (replay)
280
+ return replay;
281
+ }
282
+ await persistOperatorRequest(input.cwd, result);
283
+ return result;
284
+ }
@@ -17,10 +17,21 @@ export const DEFAULT_DAG_RETRY_CATEGORIES = [
17
17
  "unavailable",
18
18
  ];
19
19
  export const STRUCTURED_OUTPUT_RETRY_CATEGORY = "output-too-large";
20
+ export const PROTOCOL_INVALID_RETRY_CATEGORY = "protocol-invalid";
20
21
  export const STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES = [
21
22
  ...DEFAULT_DAG_RETRY_CATEGORIES,
22
23
  STRUCTURED_OUTPUT_RETRY_CATEGORY,
23
24
  ];
25
+ /** Categories allowed on nodes that declare a machine-readable outputProtocol. */
26
+ export const PROTOCOL_AWARE_DAG_RETRY_CATEGORIES = [
27
+ ...DEFAULT_DAG_RETRY_CATEGORIES,
28
+ PROTOCOL_INVALID_RETRY_CATEGORY,
29
+ ];
30
+ export const ALL_DAG_RETRY_CATEGORIES = [
31
+ ...DEFAULT_DAG_RETRY_CATEGORIES,
32
+ STRUCTURED_OUTPUT_RETRY_CATEGORY,
33
+ PROTOCOL_INVALID_RETRY_CATEGORY,
34
+ ];
24
35
  const RETRY_SAFE_PI_ROLES = new Set([
25
36
  "planner",
26
37
  "scout",
@@ -28,7 +39,7 @@ const RETRY_SAFE_PI_ROLES = new Set([
28
39
  "verifier",
29
40
  "closeout",
30
41
  ]);
31
- export const dagRetryCategorySchema = z.enum(STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES);
42
+ export const dagRetryCategorySchema = z.enum(ALL_DAG_RETRY_CATEGORIES);
32
43
  export const dagRetryBackoffSchema = z.enum(["exponential"]);
33
44
  /**
34
45
  * Opt-in retry policy for a DagTask. Generated only for safe read-only Pi
@@ -86,6 +97,14 @@ export const STRUCTURED_REQUIRED_PI_RETRY_POLICY = {
86
97
  ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
87
98
  retryCategories: [...STRUCTURED_REQUIRED_DAG_RETRY_CATEGORIES],
88
99
  };
100
+ /**
101
+ * Default retry for reviewer / recovery nodes that declare outputProtocol.
102
+ * Includes protocol-invalid so missing VERDICT lines are corrected in-node.
103
+ */
104
+ export const PROTOCOL_AWARE_PI_RETRY_POLICY = {
105
+ ...DEFAULT_READ_ONLY_PI_RETRY_POLICY,
106
+ retryCategories: [...PROTOCOL_AWARE_DAG_RETRY_CATEGORIES],
107
+ };
89
108
  /**
90
109
  * Deterministic helper: is this raw failure category eligible for retry under
91
110
  * the given policy? Pure function; executor never decides retry eligibility.
@@ -1,4 +1,5 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
2
3
  import { hostname } from "node:os";
3
4
  import path from "node:path";
4
5
  import { isHardBudgetBreached, resolveEffectiveMaxConcurrent, } from "../../application/evaluation/budget.js";
@@ -16,6 +17,7 @@ import { resolveRunningControllerIdentity } from "../../shared/package-metadata.
16
17
  import { normalizeDagPromptSources } from "./prompt-source.js";
17
18
  import { relocateConvergenceArtifactPaths, relocateRunArtifactPaths, } from "./upstream-artifacts.js";
18
19
  import { createSkillSnapshot, prepareSkillSnapshotForContinuation, writeSkillSnapshot, } from "./skill-snapshot.js";
20
+ import { captureWorkspaceCheckpoint, WORKSPACE_CHECKPOINT_START_REL, WORKSPACE_CHECKPOINT_TERMINAL_REL, writeWorkspaceCheckpoint, } from "./workspace-checkpoint.js";
19
21
  import { buildNodePrompt, buildNodePromptWithResolvedSkillInstructions, executeDagNode, } from "./node-execution.js";
20
22
  import { runConvergencePassController, shouldEnableDagConvergence, } from "./convergence/controller.js";
21
23
  import { executeDagRanksOnce, isConditionSkippedReason } from "./scheduler.js";
@@ -149,6 +151,9 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
149
151
  },
150
152
  }
151
153
  : {}),
154
+ ...(opts.workerAssociation
155
+ ? { workerAssociation: structuredClone(opts.workerAssociation) }
156
+ : {}),
152
157
  };
153
158
  initRunBudgetLedger(state, spec.budget);
154
159
  return state;
@@ -229,7 +234,17 @@ export async function runDag(spec, opts) {
229
234
  throw new Error(`failed to create run-start skill snapshot: ${error instanceof Error ? error.message : String(error)}`);
230
235
  }
231
236
  await writeRunState(runDir, state);
237
+ if (opts.workerAssociation) {
238
+ await writeJsonAtomic(path.join(runDir, ".runtime", "worker-association.json"), opts.workerAssociation);
239
+ }
232
240
  await notifyRunObserver(opts.observer, "onRunStart", state);
241
+ try {
242
+ const startCheckpoint = await captureWorkspaceCheckpoint(opts.cwd);
243
+ await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_START_REL, startCheckpoint);
244
+ }
245
+ catch (error) {
246
+ console.warn(`[run-dag] warning: failed to write workspace start checkpoint: ${error instanceof Error ? error.message : String(error)}`);
247
+ }
233
248
  if (opts.dryRun || opts.initOnly) {
234
249
  return {
235
250
  title: spec.title,
@@ -258,6 +273,34 @@ export async function runDag(spec, opts) {
258
273
  finally {
259
274
  }
260
275
  }
276
+ export async function runDagContinuation(opts) {
277
+ const { maxConcurrent } = resolveEffectiveMaxConcurrent(Math.max(1, opts.maxConcurrent ?? 4), opts.spec.budget);
278
+ const state = opts.state;
279
+ state.status = "running";
280
+ const resumedAt = new Date().toISOString();
281
+ state.runner = {
282
+ pid: process.pid,
283
+ hostname: hostname(),
284
+ startedAt: resumedAt,
285
+ heartbeatAt: resumedAt,
286
+ };
287
+ const activeRunDir = getDagRunDir(opts.cwd, "active", state.runId);
288
+ const completedRunDir = getDagRunDir(opts.cwd, "completed", state.runId);
289
+ const pausedRunDir = getDagRunDir(opts.cwd, "paused", state.runId);
290
+ return executeDagCheckpoint({
291
+ spec: opts.spec,
292
+ state,
293
+ ranks: state.ranks,
294
+ runDir: opts.runDir,
295
+ cwd: opts.cwd,
296
+ maxConcurrent,
297
+ executeNode: opts.executeNode,
298
+ observer: opts.observer,
299
+ activeRunDir,
300
+ completedRunDir,
301
+ pausedRunDir,
302
+ });
303
+ }
261
304
  export async function resumeDagRun(opts) {
262
305
  const located = await locateDagRun(opts.cwd, opts.runId);
263
306
  if (!located) {
@@ -496,6 +539,13 @@ async function executeDagCheckpoint(input) {
496
539
  finalizeTerminalRunStatus(state, spec.tasks.length);
497
540
  await persistState();
498
541
  await notifyRunObserver(input.observer, "onRunFinish", state);
542
+ try {
543
+ const terminalCheckpoint = await captureWorkspaceCheckpoint(cwd);
544
+ await writeWorkspaceCheckpoint(runDir, WORKSPACE_CHECKPOINT_TERMINAL_REL, terminalCheckpoint);
545
+ }
546
+ catch (error) {
547
+ console.warn(`[run-dag] warning: failed to write workspace terminal checkpoint: ${error instanceof Error ? error.message : String(error)}`);
548
+ }
499
549
  runDir = await moveToCompletedRunDir(runDir, completedRunDir);
500
550
  }
501
551
  await relocateRunArtifactPaths({
@@ -2,13 +2,14 @@ import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { writeTextAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { writeJsonAtomic, writeTextAtomic, } from "../../infrastructure/harness/atomic-write.js";
6
6
  import { resolveContextPolicy } from "./context-policy.js";
7
7
  import { buildDagNodePromptEnvelope } from "./prompt.js";
8
8
  import { resolveDagSkillInstructions, } from "./skill-instructions.js";
9
9
  export const SKILL_SNAPSHOT_SCHEMA_VERSION = 1;
10
10
  export const SKILL_SNAPSHOT_RESOLVER_VERSION = 1;
11
11
  export const SKILL_SNAPSHOT_REL_PATH = ".runtime/skill-snapshot.json";
12
+ export const SKILL_SNAPSHOT_CONTINUATION_REL_PATH = ".runtime/skill-snapshot-continuation.json";
12
13
  const skillReferenceSchema = z.object({
13
14
  name: z.string().min(1),
14
15
  path: z.string().min(1),
@@ -75,7 +76,7 @@ const skillSnapshotSchema = z.object({
75
76
  resolverVersion: z.literal(SKILL_SNAPSHOT_RESOLVER_VERSION),
76
77
  runId: z.string().min(1),
77
78
  createdAt: z.string().datetime(),
78
- mode: z.enum(["run-start", "legacy-resume-backfill"]),
79
+ mode: z.enum(["run-start", "legacy-resume-backfill", "continuation-from-parent"]),
79
80
  initialTaskIds: z.array(z.string().min(1)),
80
81
  bindings: z.array(skillSnapshotBindingSchema),
81
82
  profiles: z.array(skillSnapshotProfileSchema),
@@ -85,7 +86,7 @@ const skillSnapshotRefSchema = z.object({
85
86
  path: z.literal(SKILL_SNAPSHOT_REL_PATH),
86
87
  sha256: z.string().regex(/^[a-f0-9]{64}$/),
87
88
  createdAt: z.string().datetime(),
88
- mode: z.enum(["run-start", "legacy-resume-backfill"]),
89
+ mode: z.enum(["run-start", "legacy-resume-backfill", "continuation-from-parent"]),
89
90
  }).strict();
90
91
  export class DagSkillSnapshotIntegrityError extends Error {
91
92
  constructor(message, options) {
@@ -329,6 +330,24 @@ function parseSnapshot(value) {
329
330
  throw new DagSkillSnapshotIntegrityError(`skill snapshot schema validation failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
330
331
  }
331
332
  }
333
+ export async function cloneParentSkillSnapshotForContinuation(input) {
334
+ const parentSnapshot = await readSkillSnapshot(input.parentRunDir, input.parentRef, { expectedRunId: input.parentRunId });
335
+ const parentSnapshotSha256 = hashSkillSnapshot(parentSnapshot);
336
+ const cloned = {
337
+ ...structuredClone(parentSnapshot),
338
+ runId: input.newRunId,
339
+ createdAt: (input.now ?? new Date()).toISOString(),
340
+ mode: "continuation-from-parent",
341
+ };
342
+ const ref = await writeSkillSnapshot(input.newRunDir, cloned);
343
+ await writeJsonAtomic(path.join(input.newRunDir, ...SKILL_SNAPSHOT_CONTINUATION_REL_PATH.split("/")), {
344
+ schemaVersion: 1,
345
+ mode: "continuation-from-parent",
346
+ parentRunId: input.parentRunId,
347
+ parentSnapshotSha256,
348
+ });
349
+ return ref;
350
+ }
332
351
  export async function createSkillSnapshot(input) {
333
352
  const initialTaskIds = input.initialTaskIds
334
353
  ?? input.spec.tasks.map((task) => task.id);
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { campaignBudgetSchema, } from "../../application/evaluation/budget.js";
3
3
  import { assertDagPromptSourceRule } from "./prompt-source.js";
4
4
  import { dagRetryPolicySchema } from "./retry-policy.js";
5
+ import { dagOutputProtocolSchema } from "./output-protocol.js";
5
6
  export const dagComplexitySchema = z.enum(["HIGH", "MED", "LOW"]);
6
7
  export const dagNodeExecutorSchema = z.enum(["pi", "shell", "static"]);
7
8
  export const CURSOR_DAG_EXECUTOR_REMOVED_ERROR = 'executor "cursor" is no longer supported; regenerate the DAG with Pi-only writers (implement-pi / repair-pi)';
@@ -382,6 +383,12 @@ export const dagTaskSchema = z.object({
382
383
  outputContract: z.string().optional(),
383
384
  outputMode: dagOutputModeSchema.optional(),
384
385
  firstProtocolLine: z.string().min(1).optional(),
386
+ /**
387
+ * Machine-readable output protocol (phase 1: first-line-enum).
388
+ * When set with retryOnInvalid, protocol-invalid failures may auto-retry
389
+ * on safe read-only Pi nodes before the node becomes ERROR.
390
+ */
391
+ outputProtocol: dagOutputProtocolSchema.optional(),
385
392
  /**
386
393
  * Explicit opt-in for the deterministic project governance context resolver
387
394
  * (AGENTS.md chain + referenced code standards). Only tasks that set this
@@ -593,6 +593,16 @@ function validateRetryPolicyTaskConfig(task, issues) {
593
593
  });
594
594
  }
595
595
  }
596
+ function validateOutputProtocolTaskConfig(task, issues) {
597
+ if (task.outputProtocol === undefined)
598
+ return;
599
+ if (!isSafeReadOnlyPiRetryCandidate(task)) {
600
+ issues.push({
601
+ type: "invalid-output-protocol",
602
+ message: `task ${task.id} declares outputProtocol but is not a safe read-only non-dynamic Pi node; output protocol retry is only allowed for read-only/none Pi planner/scout/reviewer/verifier/closeout nodes`,
603
+ });
604
+ }
605
+ }
596
606
  function validateDecisionGateTaskConfig(task, issues) {
597
607
  if (!task.decisionGate?.enabled) {
598
608
  return;
@@ -692,6 +702,7 @@ export function validateDagSpec(spec) {
692
702
  validateStaticTaskConfig(task, issues);
693
703
  validateDecisionGateTaskConfig(task, issues);
694
704
  validateRetryPolicyTaskConfig(task, issues);
705
+ validateOutputProtocolTaskConfig(task, issues);
695
706
  validateProjectGovernanceTaskConfig(task, spec, issues);
696
707
  }
697
708
  validateSameRankWriteSetConflicts(spec, ranks, issues);
@@ -0,0 +1,163 @@
1
+ import { createHash } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { lstat, readFile, realpath } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
6
+ /**
7
+ * Workspace checkpoint for DAG continuation safety.
8
+ * Proves current repo content matches parent terminal state before importing facts.
9
+ */
10
+ export const WORKSPACE_CHECKPOINT_START_REL = ".runtime/workspace-checkpoint-start.json";
11
+ export const WORKSPACE_CHECKPOINT_TERMINAL_REL = ".runtime/workspace-checkpoint-terminal.json";
12
+ function sha256Hex(input) {
13
+ return createHash("sha256").update(input).digest("hex");
14
+ }
15
+ async function runGit(cwd, args) {
16
+ return new Promise((resolve, reject) => {
17
+ const child = spawn("git", args, {
18
+ cwd,
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ });
21
+ let stdout = "";
22
+ let stderr = "";
23
+ child.stdout.setEncoding("utf-8");
24
+ child.stderr.setEncoding("utf-8");
25
+ child.stdout.on("data", (chunk) => {
26
+ stdout += chunk;
27
+ });
28
+ child.stderr.on("data", (chunk) => {
29
+ stderr += chunk;
30
+ });
31
+ child.on("error", reject);
32
+ child.on("close", (code) => {
33
+ if (code === 0) {
34
+ resolve(stdout);
35
+ return;
36
+ }
37
+ reject(new Error(`git ${args.join(" ")} failed: ${stderr.trim() || stdout.trim()}`));
38
+ });
39
+ });
40
+ }
41
+ function normalizeGitPath(value) {
42
+ const target = value.includes(" -> ")
43
+ ? (value.split(" -> ").at(-1) ?? value)
44
+ : value;
45
+ return target.replace(/\\/g, "/").replace(/^\.\//, "");
46
+ }
47
+ function classifyPorcelainState(x, y) {
48
+ if (x === "?" || y === "?")
49
+ return "untracked";
50
+ if (x === "R" || y === "R")
51
+ return "renamed";
52
+ if (x === "D" || y === "D")
53
+ return "deleted";
54
+ if (x === "A" || y === "A")
55
+ return "added";
56
+ if (x === "M" || y === "M")
57
+ return "modified";
58
+ return "modified";
59
+ }
60
+ async function hashWorkspacePath(repoRoot, relPath, state) {
61
+ if (state === "deleted") {
62
+ return { contentSha256: sha256Hex(`deleted:${relPath}`) };
63
+ }
64
+ const abs = path.join(repoRoot, relPath);
65
+ try {
66
+ const st = await lstat(abs);
67
+ const mode = (st.mode & 0o777777).toString(8);
68
+ if (st.isSymbolicLink()) {
69
+ const target = await realpath(abs).catch(async () => {
70
+ const { readlink } = await import("node:fs/promises");
71
+ return readlink(abs);
72
+ });
73
+ return {
74
+ mode,
75
+ contentSha256: sha256Hex(`symlink:${relPath}->${target}`),
76
+ };
77
+ }
78
+ if (st.isDirectory()) {
79
+ // Submodule or directory entry — fingerprint via git ls-files / HEAD if possible.
80
+ return {
81
+ mode,
82
+ contentSha256: sha256Hex(`dir:${relPath}`),
83
+ };
84
+ }
85
+ const bytes = await readFile(abs);
86
+ return { mode, contentSha256: sha256Hex(bytes) };
87
+ }
88
+ catch {
89
+ return { contentSha256: sha256Hex(`missing:${relPath}`) };
90
+ }
91
+ }
92
+ function computeFingerprint(input) {
93
+ const payload = JSON.stringify({
94
+ gitHeadSha: input.gitHeadSha,
95
+ statusPorcelainSha256: input.statusPorcelainSha256,
96
+ changedPaths: input.changedPaths,
97
+ });
98
+ return sha256Hex(payload);
99
+ }
100
+ /**
101
+ * Capture a workspace checkpoint for the current working tree.
102
+ */
103
+ export async function captureWorkspaceCheckpoint(repoRoot, options) {
104
+ const capturedAt = (options?.now ?? new Date()).toISOString();
105
+ const gitHeadSha = (await runGit(repoRoot, ["rev-parse", "HEAD"])).trim();
106
+ const porcelain = await runGit(repoRoot, [
107
+ "status",
108
+ "--porcelain=v1",
109
+ "--untracked-files=all",
110
+ ]);
111
+ const statusPorcelainSha256 = sha256Hex(porcelain);
112
+ const changedPaths = [];
113
+ for (const line of porcelain.split("\n")) {
114
+ const trimmed = line.trimEnd();
115
+ if (!trimmed)
116
+ continue;
117
+ const x = trimmed[0] ?? " ";
118
+ const y = trimmed[1] ?? " ";
119
+ const rawPath = trimmed.slice(3);
120
+ const relPath = normalizeGitPath(rawPath);
121
+ const state = classifyPorcelainState(x, y);
122
+ const hashed = await hashWorkspacePath(repoRoot, relPath, state);
123
+ changedPaths.push({
124
+ path: relPath,
125
+ state,
126
+ ...hashed,
127
+ });
128
+ }
129
+ changedPaths.sort((a, b) => a.path.localeCompare(b.path));
130
+ const fingerprint = computeFingerprint({
131
+ gitHeadSha,
132
+ statusPorcelainSha256,
133
+ changedPaths,
134
+ });
135
+ return {
136
+ schemaVersion: 1,
137
+ capturedAt,
138
+ gitHeadSha,
139
+ statusPorcelainSha256,
140
+ changedPaths,
141
+ fingerprint,
142
+ };
143
+ }
144
+ export async function writeWorkspaceCheckpoint(runDir, relPath, checkpoint) {
145
+ await writeJsonAtomic(path.join(runDir, relPath), checkpoint);
146
+ }
147
+ export async function readWorkspaceCheckpoint(runDir, relPath) {
148
+ try {
149
+ const raw = JSON.parse(await readFile(path.join(runDir, relPath), "utf8"));
150
+ if (raw?.schemaVersion !== 1 || typeof raw.fingerprint !== "string") {
151
+ return undefined;
152
+ }
153
+ return raw;
154
+ }
155
+ catch {
156
+ return undefined;
157
+ }
158
+ }
159
+ export function workspaceFingerprintsMatch(left, right) {
160
+ if (!left?.fingerprint || !right?.fingerprint)
161
+ return false;
162
+ return left.fingerprint === right.fingerprint;
163
+ }