@tea-agent/loop-agent 0.11.0 → 0.13.0-alpha.0

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 (123) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +33 -4
  3. package/dist/application/dag/generate-task-dag.js +45 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/cli/command-definitions.js +10 -3
  7. package/dist/commands/init.js +74 -7
  8. package/dist/commands/knowledge.js +129 -31
  9. package/dist/governance/manifest-types.js +3 -0
  10. package/dist/shared/package-metadata.js +135 -0
  11. package/dist/task/config-types.js +6 -1
  12. package/dist/worker/cli.js +99 -2
  13. package/dist/worker/delivery/package.js +3 -3
  14. package/dist/worker/feature/decision-loader.js +37 -6
  15. package/dist/worker/feature/next-action.js +10 -2
  16. package/dist/worker/feature/ready-plan-projection.js +81 -0
  17. package/dist/worker/feature/reducer.js +2 -1
  18. package/dist/worker/feature/review.js +19 -2
  19. package/dist/worker/feature/run.js +27 -2
  20. package/dist/worker/follow-up/approve.js +5 -2
  21. package/dist/worker/follow-up/factory.js +1 -1
  22. package/dist/worker/observability/event-history.js +216 -0
  23. package/dist/worker/observability/read-model.js +552 -118
  24. package/dist/worker/observe/paths.js +17 -0
  25. package/dist/worker/observe/routes.js +310 -23
  26. package/dist/worker/observe/server.js +59 -1
  27. package/dist/worker/observe/spec-evidence.js +281 -0
  28. package/dist/worker/observe/static/api.js +46 -0
  29. package/dist/worker/observe/static/app.js +120 -2598
  30. package/dist/worker/observe/static/constants.js +148 -0
  31. package/dist/worker/observe/static/copy.js +67 -0
  32. package/dist/worker/observe/static/dag-helpers.js +172 -0
  33. package/dist/worker/observe/static/dag-model.js +72 -0
  34. package/dist/worker/observe/static/dom.js +61 -0
  35. package/dist/worker/observe/static/format-pool.js +67 -0
  36. package/dist/worker/observe/static/format.js +292 -0
  37. package/dist/worker/observe/static/index.html +300 -82
  38. package/dist/worker/observe/static/kpi.js +94 -0
  39. package/dist/worker/observe/static/relations.js +133 -0
  40. package/dist/worker/observe/static/router.js +93 -0
  41. package/dist/worker/observe/static/run-processing.js +148 -0
  42. package/dist/worker/observe/static/shell-chrome.js +68 -0
  43. package/dist/worker/observe/static/state.js +253 -0
  44. package/dist/worker/observe/static/styles.css +1731 -495
  45. package/dist/worker/observe/static/views/batch.js +227 -0
  46. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  47. package/dist/worker/observe/static/views/dag-inspector.js +596 -0
  48. package/dist/worker/observe/static/views/dag.js +362 -0
  49. package/dist/worker/observe/static/views/dashboard.js +445 -0
  50. package/dist/worker/observe/static/views/failures.js +143 -0
  51. package/dist/worker/observe/static/views/feature.js +492 -0
  52. package/dist/worker/observe/static/views/pool.js +350 -0
  53. package/dist/worker/observe/static/views/run.js +453 -0
  54. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  55. package/dist/worker/observe/static/views/shell.js +7 -0
  56. package/dist/worker/observe/static/views/task.js +314 -0
  57. package/dist/worker/observe/static/views/timeline.js +163 -0
  58. package/dist/worker/pool/doctor.js +165 -0
  59. package/dist/worker/pool/migrate-state.js +303 -0
  60. package/dist/worker/pool/run-store.js +205 -17
  61. package/dist/worker/pool/types.js +17 -1
  62. package/dist/worker/pool/validation.js +100 -15
  63. package/dist/worker/report/morning-report.js +12 -2
  64. package/dist/worker/runner/run-ready.js +41 -26
  65. package/dist/worker/task-graph/ready-planner.js +136 -0
  66. package/dist/workflows/dag/controller-identity.js +104 -0
  67. package/dist/workflows/dag/convergence/controller.js +16 -8
  68. package/dist/workflows/dag/failure-routing.js +12 -1
  69. package/dist/workflows/dag/init-hybrid.js +1233 -11
  70. package/dist/workflows/dag/node-execution.js +123 -29
  71. package/dist/workflows/dag/repair-artifact.js +91 -0
  72. package/dist/workflows/dag/report.js +50 -0
  73. package/dist/workflows/dag/retry-policy.js +138 -0
  74. package/dist/workflows/dag/runner.js +32 -0
  75. package/dist/workflows/dag/runtime-contract.js +87 -0
  76. package/dist/workflows/dag/skill-snapshot.js +2 -0
  77. package/dist/workflows/dag/types.js +45 -1
  78. package/dist/workflows/dag/validate.js +68 -4
  79. package/docs/README.md +1 -1
  80. package/docs/agent-dag-recovery-playbook.md +9 -0
  81. package/docs/agent-dag-runner.md +26 -1
  82. package/docs/architecture/dag-execution.md +6 -0
  83. package/docs/architecture/evolution.md +7 -5
  84. package/docs/architecture/facts-and-state.md +15 -2
  85. package/docs/architecture/worker-and-feature.md +6 -2
  86. package/docs/decisions/README.md +3 -0
  87. package/docs/design/README.md +12 -3
  88. package/docs/exec-plans/active/README.md +2 -2
  89. package/docs/exec-plans/completed/README.md +12 -0
  90. package/docs/feature-workflow.md +108 -2
  91. package/docs/loop-agent-harness.md +15 -4
  92. package/docs/progress/README.md +22 -0
  93. package/docs/reports/README.md +14 -2
  94. package/docs/templates/agent-dag-report.schema.json +17 -0
  95. package/docs/templates/agent-dag.schema.json +69 -1
  96. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  97. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  98. package/docs/templates/backend-test-dag.json +288 -0
  99. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  100. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  101. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
  102. package/docs/templates/knowledge-sync-dag.json +177 -0
  103. package/docs/templates/knowledge-sync-draft.schema.json +71 -0
  104. package/docs/verification-matrix.md +2 -1
  105. package/package.json +8 -2
  106. package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
  107. package/scripts/kb-graph-incremental-prepare.mjs +372 -0
  108. package/scripts/kb-graph-incremental-prepare.sh +5 -0
  109. package/scripts/kb-graph-materialize.mjs +105 -0
  110. package/scripts/kb-graph-materialize.sh +4 -0
  111. package/scripts/kb-graph-promote.mjs +153 -0
  112. package/scripts/kb-graph-promote.sh +4 -0
  113. package/scripts/kb-query.mjs +554 -0
  114. package/scripts/kb-query.sh +5 -0
  115. package/skills/agent-worker/SKILL.md +3 -1
  116. package/skills/agent-worker/references/agent-worker-operator.md +18 -1
  117. package/skills/frontend-design-review/SKILL.md +26 -24
  118. package/skills/frontend-implementation/SKILL.md +29 -26
  119. package/skills/frontend-implementation/references/node-contracts.md +50 -19
  120. package/skills/frontend-review/SKILL.md +1 -1
  121. package/skills/loop-agent/references/command-reference.md +2 -0
  122. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  123. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -3,10 +3,12 @@ import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, writeHuman
3
3
  import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
4
4
  import { buildDagNodePromptEnvelope } from "./prompt.js";
5
5
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
6
+ import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, } from "./retry-policy.js";
7
+ import { writeDagNodeJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
6
8
  import { assertSkillSnapshotCoversSpec, buildNodePromptFromSnapshot, isDagSkillSnapshotIntegrityError, readSkillSnapshot, } from "./skill-snapshot.js";
7
9
  import { resolveDagSkillInstructions, skillInstructionMetadata, } from "./skill-instructions.js";
8
10
  import { resolveDagNodeSkills } from "./skills.js";
9
- import { parseRepairArtifactFromText, validateRepairArtifactScope, } from "./repair-artifact.js";
11
+ import { parseRepairArtifactFromText, resolveRepairTaskForGate, validateRepairArtifactScope, } from "./repair-artifact.js";
10
12
  import { resolveModelForTask, } from "./types.js";
11
13
  export function buildNodePrompt(spec, task, upstream) {
12
14
  return buildDagNodePromptEnvelope({
@@ -54,10 +56,6 @@ function assertRepairArtifactVerdictMatchesSupervisor(input) {
54
56
  throw new Error(`repair artifact gate failed: verdict mismatch between supervisor ${supervisorVerdict} and REPAIR_ARTIFACT_JSON ${input.artifactVerdict}`);
55
57
  }
56
58
  }
57
- function findRepairTaskForGate(input) {
58
- return Array.from(input.tasksById.values()).find((candidate) => candidate.depends_on.includes(input.gateTask.id) &&
59
- candidate.id === "repair-pi");
60
- }
61
59
  function parseSupervisorRepairArtifact(node) {
62
60
  const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
63
61
  return parseRepairArtifactFromText(text);
@@ -76,12 +74,21 @@ function validateRepairArtifactGateBeforeShell(input) {
76
74
  artifactVerdict: parsed.artifact.verdict,
77
75
  });
78
76
  upstream.repairArtifact = parsed.artifact;
77
+ const resolution = resolveRepairTaskForGate({
78
+ tasks: Array.from(input.tasksById.values()),
79
+ gateTask: input.task,
80
+ });
81
+ if (!resolution.ok) {
82
+ // A pass verdict does not need a repair writer; only fail closed when the
83
+ // supervisor requested a revision that must be routed to a repair node.
84
+ if (parsed.artifact.verdict === "request-revision") {
85
+ throw new Error(`repair artifact gate failed: ${resolution.reason}`);
86
+ }
87
+ return;
88
+ }
79
89
  const scoped = validateRepairArtifactScope({
80
90
  artifact: parsed.artifact,
81
- repairTask: findRepairTaskForGate({
82
- tasksById: input.tasksById,
83
- gateTask: input.task,
84
- }),
91
+ repairTask: resolution.repairTask,
85
92
  });
86
93
  if (!scoped.ok) {
87
94
  throw new Error(`repair artifact gate failed: ${scoped.reason}`);
@@ -96,6 +103,15 @@ function recordRepairArtifactForSupervisorNode(input) {
96
103
  input.node.repairArtifact = parsed.artifact;
97
104
  }
98
105
  }
106
+ function sleep(ms) {
107
+ return new Promise((resolve) => setTimeout(resolve, ms));
108
+ }
109
+ function sumAttemptMetric(attempts, select) {
110
+ const values = attempts.map(select).filter((value) => value !== undefined);
111
+ return values.length > 0
112
+ ? values.reduce((sum, value) => sum + value, 0)
113
+ : undefined;
114
+ }
99
115
  async function notifyNodeObserver(observer, event, nodeId, state, chunk) {
100
116
  try {
101
117
  if (event === "onNodeOutput") {
@@ -223,31 +239,109 @@ export async function executeDagNode(input) {
223
239
  node.resolvedSkills = resolvedSkills;
224
240
  await writeNodeSkillArtifacts(runDir, nodeId, resolvedSkills);
225
241
  const model = resolveModelForTask(task, spec.executorModels);
226
- const started = Date.now();
227
- try {
228
- validateRepairArtifactGateBeforeShell({
229
- task,
230
- tasksById,
231
- state,
232
- });
233
- const result = await executeNode({ task, cwd, model, prompt });
234
- node.durationMs = result.durationMs ?? Date.now() - started;
242
+ const retryPolicy = task.retryPolicy && isSafeReadOnlyPiRetryCandidate(task)
243
+ ? task.retryPolicy
244
+ : undefined;
245
+ const maxAttempts = retryPolicy?.maxAttempts ?? 1;
246
+ const attempts = [];
247
+ let totalBackoffMs = 0;
248
+ let terminalResult;
249
+ for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
250
+ const attemptStartedAt = new Date().toISOString();
251
+ const attemptStarted = Date.now();
252
+ let result;
253
+ try {
254
+ validateRepairArtifactGateBeforeShell({
255
+ task,
256
+ tasksById,
257
+ state,
258
+ });
259
+ result = await executeNode({ task, cwd, model, prompt });
260
+ }
261
+ catch (error) {
262
+ result = {
263
+ ok: false,
264
+ stdout: "",
265
+ stderr: error instanceof Error ? error.message : String(error),
266
+ durationMs: Date.now() - attemptStarted,
267
+ };
268
+ }
269
+ const attemptFinishedAt = new Date().toISOString();
270
+ const attemptRecord = {
271
+ attempt: attemptNumber,
272
+ startedAt: attemptStartedAt,
273
+ finishedAt: attemptFinishedAt,
274
+ durationMs: result.durationMs ?? Date.now() - attemptStarted,
275
+ ok: result.ok,
276
+ stdout: result.stdout,
277
+ stderr: result.stderr,
278
+ assistantText: result.assistantText,
279
+ failureCategory: result.failureCategory,
280
+ backend: result.backend,
281
+ sdkAttempted: result.sdkAttempted,
282
+ tokensUsed: result.tokensUsed,
283
+ parsedEvents: result.parsedEvents,
284
+ artifactPath: `${nodeId}/attempt-${attemptNumber}.json`,
285
+ };
286
+ if (retryPolicy !== undefined) {
287
+ attempts.push(attemptRecord);
288
+ // Persist immutable attempt evidence BEFORE waiting for the next attempt
289
+ // and before applying the result to the node record, so a later success
290
+ // never overwrites prior failure evidence.
291
+ await writeDagNodeJsonArtifact(runDir, nodeId, `attempt-${attemptNumber}.json`, attemptRecord);
292
+ node.attempts = attempts;
293
+ }
294
+ // Reflect the latest attempt on the node so progress is observable,
295
+ // but keep node.status RUNNING while retry is still possible.
296
+ node.durationMs =
297
+ retryPolicy === undefined
298
+ ? attemptRecord.durationMs
299
+ : attempts.reduce((sum, attempt) => sum + (attempt.durationMs ?? 0), 0) +
300
+ totalBackoffMs;
235
301
  node.stdout = result.stdout;
236
302
  node.stderr = result.stderr;
237
303
  node.failureCategory = result.failureCategory;
238
- if (result.assistantText !== undefined) {
239
- node.assistantText = result.assistantText;
240
- }
241
- if (result.backend !== undefined)
242
- node.backend = result.backend;
243
- if (result.sdkAttempted !== undefined) {
244
- node.sdkAttempted = result.sdkAttempted;
304
+ node.assistantText = result.assistantText;
305
+ node.backend = result.backend;
306
+ node.sdkAttempted = result.sdkAttempted;
307
+ node.tokensUsed =
308
+ retryPolicy === undefined
309
+ ? result.tokensUsed
310
+ : sumAttemptMetric(attempts, (attempt) => attempt.tokensUsed);
311
+ node.parsedEvents =
312
+ retryPolicy === undefined
313
+ ? result.parsedEvents
314
+ : sumAttemptMetric(attempts, (attempt) => attempt.parsedEvents);
315
+ node.lastActivityAt = attemptFinishedAt;
316
+ if (retryPolicy !== undefined) {
317
+ state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
318
+ await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
319
+ await input.persistState();
245
320
  }
246
- if (result.tokensUsed !== undefined)
247
- node.tokensUsed = result.tokensUsed;
248
- if (result.parsedEvents !== undefined) {
249
- node.parsedEvents = result.parsedEvents;
321
+ terminalResult = result;
322
+ if (result.ok)
323
+ break;
324
+ const canRetry = retryPolicy !== undefined && attemptNumber < maxAttempts;
325
+ const retryable = retryPolicy !== undefined &&
326
+ isRetryablePiFailureCategory(result.failureCategory, {
327
+ retryCategories: retryPolicy.retryCategories,
328
+ });
329
+ if (!canRetry || !retryable)
330
+ break;
331
+ const delayMs = computeBackoffDelayMs(attemptNumber + 1, retryPolicy);
332
+ if (delayMs > 0) {
333
+ totalBackoffMs += delayMs;
334
+ await sleep(delayMs);
335
+ // Keep lastActivityAt fresh so backoff wait is not misread as node-quiet.
336
+ node.lastActivityAt = new Date().toISOString();
337
+ state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
338
+ await writeNodeRecord(runDir, nodeId, state.nodes[nodeId]);
339
+ await input.persistState();
250
340
  }
341
+ }
342
+ const result = terminalResult;
343
+ const started = Date.now();
344
+ try {
251
345
  recordRepairArtifactForSupervisorNode({
252
346
  task,
253
347
  node,
@@ -123,6 +123,97 @@ export function validateRepairArtifactScope(input) {
123
123
  }
124
124
  return { ok: true, artifact };
125
125
  }
126
+ /**
127
+ * Return the list of governed-writer contract violations for a candidate repair
128
+ * node. An empty array means the node is a governed Pi writer eligible to
129
+ * receive a structured repair artifact.
130
+ */
131
+ export function repairWriterContractIssues(task) {
132
+ const issues = [];
133
+ if (task.executor !== "pi") {
134
+ issues.push(`executor must be pi (got ${task.executor ?? "unset"})`);
135
+ }
136
+ if (task.toolProfile !== "write") {
137
+ issues.push(`toolProfile must be write (got ${task.toolProfile ?? "unset"})`);
138
+ }
139
+ if (task.writePolicy !== "exclusive") {
140
+ issues.push(`writePolicy must be exclusive (got ${task.writePolicy ?? "unset"})`);
141
+ }
142
+ const allowed = task.allowedPaths ?? [];
143
+ const writeSet = task.writeSet ?? [];
144
+ const forbidden = task.forbiddenPaths ?? [];
145
+ if (allowed.length === 0) {
146
+ issues.push("allowedPaths must be non-empty");
147
+ }
148
+ if (writeSet.length === 0) {
149
+ issues.push("writeSet must be non-empty");
150
+ }
151
+ for (const entry of writeSet) {
152
+ const normalized = normalizePath(entry);
153
+ if (forbidden.some((pattern) => pathMatchesPattern(normalized, pattern))) {
154
+ issues.push(`writeSet entry "${entry}" conflicts with forbiddenPaths`);
155
+ }
156
+ }
157
+ return issues;
158
+ }
159
+ export function isGovernedRepairWriter(task) {
160
+ return repairWriterContractIssues(task).length === 0;
161
+ }
162
+ /**
163
+ * Resolve the repair writer a repair-artifact gate feeds, using the explicit
164
+ * `repairNodeId` reference when present and otherwise deriving a unique safe
165
+ * downstream Pi writer. Never guesses by node name.
166
+ */
167
+ export function resolveRepairTaskForGate(input) {
168
+ const gate = input.gateTask.shell?.repairArtifactGate;
169
+ if (!gate) {
170
+ return {
171
+ ok: false,
172
+ reason: `task ${input.gateTask.id} has no repairArtifactGate`,
173
+ };
174
+ }
175
+ const directDownstream = input.tasks.filter((task) => task.depends_on.includes(input.gateTask.id));
176
+ if (gate.repairNodeId) {
177
+ const declared = input.tasks.find((task) => task.id === gate.repairNodeId);
178
+ if (!declared) {
179
+ return {
180
+ ok: false,
181
+ reason: `repair artifact gate "${input.gateTask.id}" declares repairNodeId "${gate.repairNodeId}" but no task with that id exists`,
182
+ };
183
+ }
184
+ if (!declared.depends_on.includes(input.gateTask.id)) {
185
+ return {
186
+ ok: false,
187
+ reason: `repair node "${declared.id}" declared by gate "${input.gateTask.id}" must depend_on the gate`,
188
+ };
189
+ }
190
+ const issues = repairWriterContractIssues(declared);
191
+ if (issues.length > 0) {
192
+ return {
193
+ ok: false,
194
+ reason: `repair node "${declared.id}" declared by gate "${input.gateTask.id}" is not a governed Pi writer: ${issues.join("; ")}`,
195
+ };
196
+ }
197
+ return { ok: true, repairTask: declared };
198
+ }
199
+ const candidates = directDownstream.filter(isGovernedRepairWriter);
200
+ if (candidates.length === 1) {
201
+ return { ok: true, repairTask: candidates[0] };
202
+ }
203
+ const candidateIds = directDownstream.map((task) => task.id).join(", ") || "none";
204
+ if (candidates.length === 0) {
205
+ return {
206
+ ok: false,
207
+ reason: `repair artifact gate "${input.gateTask.id}" has no repairNodeId and no unique governed Pi writer among direct downstream nodes (${candidateIds}); regenerate the DAG with an explicit repairNodeId`,
208
+ };
209
+ }
210
+ return {
211
+ ok: false,
212
+ reason: `repair artifact gate "${input.gateTask.id}" has no repairNodeId and multiple governed Pi writer candidates downstream (${candidates
213
+ .map((task) => task.id)
214
+ .join(", ")}); declare repairNodeId to disambiguate`,
215
+ };
216
+ }
126
217
  export function formatRepairArtifactForPrompt(artifact) {
127
218
  return [
128
219
  "Repair artifact:",
@@ -10,6 +10,7 @@ import { dagNormalizedFailureCategorySchema, normalizeDagFailureCategory, } from
10
10
  import { dagProductLineFailureCategoryValues, routeDagFailure, } from "./failure-routing.js";
11
11
  import { DAG_RECOVERY_ACTIONS, planDagRecovery, } from "./recovery-recommendation.js";
12
12
  import { dagNodeExecutorSchema, dagNodeStatusSchema, LEGACY_TOP_LEVEL_MODELS_ERROR, parseDagSpec, resolveModelForTask, } from "./types.js";
13
+ import { checkRuntimeContractCompatible, DAG_CONTROLLER_CAPABILITIES, } from "./runtime-contract.js";
13
14
  export const DAG_REPORT_SCHEMA_VERSION = 1;
14
15
  export const DAG_REPORT_SCHEMA_RELATIVE_PATH = "docs/templates/agent-dag-report.schema.json";
15
16
  const dagRunLifecycleSchema = z.enum(["active", "paused", "completed"]);
@@ -169,6 +170,22 @@ const dagRunReportEntrySchema = z
169
170
  convergence: dagConvergenceReportSchema.optional(),
170
171
  pausedByNodeId: z.string().optional(),
171
172
  pauseReason: z.string().optional(),
173
+ controllerIdentity: z
174
+ .object({
175
+ status: z.enum(["pinned", "legacy-unpinned"]),
176
+ capturedAt: z.string().min(1),
177
+ packageName: z.string().optional(),
178
+ packageVersion: z.string().optional(),
179
+ packageFingerprint: z.string().optional(),
180
+ binarySha256: z.string().optional(),
181
+ runtimeContractCompatibility: z.enum([
182
+ "compatible",
183
+ "incompatible",
184
+ "legacy-unspecified",
185
+ ]),
186
+ runtimeContractReason: z.string().optional(),
187
+ })
188
+ .optional(),
172
189
  executorJsonl: dagArtifactRefSchema,
173
190
  nodes: z.array(dagNodeReportRowSchema),
174
191
  })
@@ -291,6 +308,38 @@ function resolvePrimaryRecovery(run, primaryNode) {
291
308
  autoRetryEligible: false,
292
309
  };
293
310
  }
311
+ function summarizeControllerIdentityForReport(ref, spec) {
312
+ if (!ref)
313
+ return {};
314
+ const compatibility = spec.runtimeContract
315
+ ? checkRuntimeContractCompatible(spec.runtimeContract, {
316
+ ...DAG_CONTROLLER_CAPABILITIES,
317
+ ...(ref.packageVersion
318
+ ? { controllerVersion: ref.packageVersion }
319
+ : {}),
320
+ })
321
+ : { ok: true };
322
+ return {
323
+ controllerIdentity: {
324
+ status: ref.status,
325
+ capturedAt: ref.capturedAt,
326
+ ...(ref.packageName ? { packageName: ref.packageName } : {}),
327
+ ...(ref.packageVersion ? { packageVersion: ref.packageVersion } : {}),
328
+ ...(ref.packageFingerprint
329
+ ? { packageFingerprint: ref.packageFingerprint }
330
+ : {}),
331
+ ...(ref.binarySha256 ? { binarySha256: ref.binarySha256 } : {}),
332
+ runtimeContractCompatibility: spec.runtimeContract
333
+ ? compatibility.ok
334
+ ? "compatible"
335
+ : "incompatible"
336
+ : "legacy-unspecified",
337
+ ...(!compatibility.ok
338
+ ? { runtimeContractReason: compatibility.reason }
339
+ : {}),
340
+ },
341
+ };
342
+ }
294
343
  function collectTransitiveDescendantNodeIds(spec, rootNodeId) {
295
344
  const childrenByParent = new Map();
296
345
  for (const task of spec.tasks) {
@@ -452,6 +501,7 @@ export async function buildDagRunReportEntry(input) {
452
501
  recoveryRecommendation,
453
502
  pausedByNodeId: input.state.pausedByNodeId,
454
503
  pauseReason: input.state.pauseReason,
504
+ ...summarizeControllerIdentityForReport(input.state.controllerIdentityRef, input.spec),
455
505
  nodes,
456
506
  };
457
507
  const primaryNode = resolvePrimaryFailureNode(partialEntry);
@@ -0,0 +1,138 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Failure categories that are safe to auto-retry for read-only Pi nodes.
4
+ *
5
+ * These are environmental/transient failures with no repository write
6
+ * side-effects: model connection interruption, provider rate-limiting,
7
+ * temporary provider unavailability, or request timeout.
8
+ *
9
+ * `quota` is intentionally NOT included: a quota exhaustion is an account
10
+ * billing/plan state, not a transient rate limit, and retrying immediately
11
+ * will not help and may waste budget.
12
+ */
13
+ export const DEFAULT_DAG_RETRY_CATEGORIES = [
14
+ "timeout",
15
+ "network",
16
+ "rate-limit",
17
+ "unavailable",
18
+ ];
19
+ const RETRY_SAFE_PI_ROLES = new Set([
20
+ "planner",
21
+ "scout",
22
+ "reviewer",
23
+ "verifier",
24
+ "closeout",
25
+ ]);
26
+ export const dagRetryCategorySchema = z.enum(DEFAULT_DAG_RETRY_CATEGORIES);
27
+ export const dagRetryBackoffSchema = z.enum(["exponential"]);
28
+ /**
29
+ * Opt-in retry policy for a DagTask. Generated only for safe read-only Pi
30
+ * nodes. Validation (validate.ts) rejects retryPolicy on writers, dynamic,
31
+ * shell, static, and decision-gate nodes.
32
+ */
33
+ export const dagRetryPolicySchema = z
34
+ .object({
35
+ /**
36
+ * Total number of attempts (including the first try). Must be 1..5.
37
+ * A value of 1 disables retry (one attempt, no retry).
38
+ */
39
+ maxAttempts: z.number().int().positive().max(5),
40
+ backoff: dagRetryBackoffSchema.default("exponential"),
41
+ /** Initial delay before the second attempt, in milliseconds. */
42
+ initialDelayMs: z.number().int().nonnegative().default(2000),
43
+ /** Upper bound on a single backoff delay, in milliseconds. */
44
+ maxDelayMs: z.number().int().nonnegative().default(30000),
45
+ /**
46
+ * Explicit set of failure categories eligible for retry. Defaults to
47
+ * {@link DEFAULT_DAG_RETRY_CATEGORIES}. `quota` and other categories
48
+ * are never retried.
49
+ */
50
+ retryCategories: z
51
+ .array(dagRetryCategorySchema)
52
+ .default([...DEFAULT_DAG_RETRY_CATEGORIES]),
53
+ })
54
+ .superRefine((policy, ctx) => {
55
+ if (policy.maxDelayMs < policy.initialDelayMs) {
56
+ ctx.addIssue({
57
+ code: z.ZodIssueCode.custom,
58
+ message: "retryPolicy.maxDelayMs must be >= retryPolicy.initialDelayMs",
59
+ path: ["maxDelayMs"],
60
+ });
61
+ }
62
+ });
63
+ /**
64
+ * The default retry policy applied to safe generated read-only Pi nodes.
65
+ * Total attempts: 3, exponential backoff with cap.
66
+ */
67
+ export const DEFAULT_READ_ONLY_PI_RETRY_POLICY = {
68
+ maxAttempts: 3,
69
+ backoff: "exponential",
70
+ initialDelayMs: 2000,
71
+ maxDelayMs: 30000,
72
+ retryCategories: [...DEFAULT_DAG_RETRY_CATEGORIES],
73
+ };
74
+ /**
75
+ * Deterministic helper: is this raw failure category eligible for retry under
76
+ * the given policy? Pure function; executor never decides retry eligibility.
77
+ */
78
+ export function isRetryablePiFailureCategory(rawFailureCategory, options = {}) {
79
+ if (!rawFailureCategory)
80
+ return false;
81
+ const categories = options.retryCategories ?? DEFAULT_DAG_RETRY_CATEGORIES;
82
+ return categories.includes(rawFailureCategory);
83
+ }
84
+ /**
85
+ * Deterministic helper: is this task a safe candidate for read-only Pi retry?
86
+ *
87
+ * A safe candidate is:
88
+ * - executor === "pi"
89
+ * - role is planner/scout/reviewer/verifier/closeout (never supervisor/implementer)
90
+ * - writePolicy is read-only/none/default and toolProfile is not write
91
+ * - NOT dynamic (no dynamicExpansion/Reduction/Condition/LoopUntil)
92
+ * - NOT a decision gate
93
+ *
94
+ * Writers and source supervisors can have non-idempotent side effects or
95
+ * control-flow meaning and must not auto-retry. Dynamic nodes expand into
96
+ * children and must not retry at the controller level.
97
+ */
98
+ export function isSafeReadOnlyPiRetryCandidate(task) {
99
+ if (task.executor !== "pi")
100
+ return false;
101
+ if (!task.role || !RETRY_SAFE_PI_ROLES.has(task.role))
102
+ return false;
103
+ if (task.toolProfile === "write")
104
+ return false;
105
+ if (task.writePolicy !== undefined &&
106
+ task.writePolicy !== "read-only" &&
107
+ task.writePolicy !== "none") {
108
+ return false;
109
+ }
110
+ if (task.decisionGate?.enabled)
111
+ return false;
112
+ if (task.dynamicExpansion ||
113
+ task.dynamicReduction ||
114
+ task.dynamicCondition ||
115
+ task.dynamicLoopUntil) {
116
+ return false;
117
+ }
118
+ return true;
119
+ }
120
+ /**
121
+ * Compute the backoff delay (in ms) to wait before the given attempt number.
122
+ *
123
+ * attemptNumber is 1-based: the delay returned is the wait BEFORE that attempt
124
+ * runs (i.e. the wait between attempt N-1 and attempt N). attemptNumber <= 1
125
+ * returns 0 (no wait before the first try).
126
+ *
127
+ * Exponential: initialDelayMs * 2^(attemptNumber - 2), capped at maxDelayMs.
128
+ */
129
+ export function computeBackoffDelayMs(attemptNumber, policy) {
130
+ if (attemptNumber <= 1)
131
+ return 0;
132
+ if (policy.backoff !== "exponential") {
133
+ return Math.min(policy.initialDelayMs, policy.maxDelayMs);
134
+ }
135
+ const exponent = attemptNumber - 2;
136
+ const raw = policy.initialDelayMs * Math.pow(2, exponent);
137
+ return Math.min(raw, policy.maxDelayMs);
138
+ }
@@ -7,6 +7,9 @@ import { moveToCompletedRunDir, moveToPausedRunDir, prepareActiveRunDir, writeRu
7
7
  import { createDagNodeExecutor } from "./executor-registry.js";
8
8
  import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
9
9
  import { assertValidDagSpec } from "./validate.js";
10
+ import { assertRuntimeContractCompatible, DAG_CONTROLLER_CAPABILITIES, } from "./runtime-contract.js";
11
+ import { captureControllerIdentity, verifyControllerIdentityForResume, } from "./controller-identity.js";
12
+ import { resolveRunningControllerIdentity } from "../../shared/package-metadata.js";
10
13
  import { normalizeDagPromptSources } from "./prompt-source.js";
11
14
  import { relocateConvergenceArtifactPaths, relocateRunArtifactPaths, } from "./upstream-artifacts.js";
12
15
  import { createSkillSnapshot, prepareSkillSnapshotForContinuation, writeSkillSnapshot, } from "./skill-snapshot.js";
@@ -145,6 +148,15 @@ export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "
145
148
  }
146
149
  export async function runDag(spec, opts) {
147
150
  assertValidDagSpec(spec);
151
+ // Capability preflight must fail closed before any run directory or node work.
152
+ const runningIdentity = resolveRunningControllerIdentity();
153
+ if (!runningIdentity) {
154
+ throw new Error("running controller identity could not be resolved; refuse to create an unpinned DAG run");
155
+ }
156
+ assertRuntimeContractCompatible(spec, {
157
+ ...DAG_CONTROLLER_CAPABILITIES,
158
+ controllerVersion: runningIdentity.packageVersion,
159
+ });
148
160
  const { ranks } = topoSortToRanks(spec);
149
161
  const maxConcurrent = Math.max(1, opts.maxConcurrent ?? 4);
150
162
  let runId = opts.runId;
@@ -163,6 +175,12 @@ export async function runDag(spec, opts) {
163
175
  const runDir = activeRunDir;
164
176
  await prepareActiveRunDir(runDir);
165
177
  await writeRunSpec(runDir, spec);
178
+ // Freeze the running controller identity as a run-owned fact before the
179
+ // first canonical state write, reusing the identity resolved for preflight.
180
+ state.controllerIdentityRef = await captureControllerIdentity({
181
+ runDir,
182
+ identity: runningIdentity,
183
+ });
166
184
  // Resolve and snapshot all skill profiles before any node executes.
167
185
  try {
168
186
  const snapshot = await createSkillSnapshot({
@@ -224,6 +242,20 @@ export async function resumeDagRun(opts) {
224
242
  if (isTerminalDagRunStatus(state.status)) {
225
243
  throw new Error(`dag run ${opts.runId} is already terminal (status=${state.status})`);
226
244
  }
245
+ // Runtime contract + controller identity must be re-verified before executing
246
+ // any remaining node on resume; drift fails closed.
247
+ const runningIdentity = resolveRunningControllerIdentity();
248
+ if (!runningIdentity) {
249
+ throw new Error("running controller identity could not be resolved; refuse to resume an unpinned DAG run");
250
+ }
251
+ assertRuntimeContractCompatible(spec, {
252
+ ...DAG_CONTROLLER_CAPABILITIES,
253
+ controllerVersion: runningIdentity.packageVersion,
254
+ });
255
+ await verifyControllerIdentityForResume({
256
+ runDir,
257
+ state,
258
+ });
227
259
  const decisionNodeId = state.humanDecisionNodeId;
228
260
  if (!decisionNodeId) {
229
261
  throw new Error(`dag run ${opts.runId} has no human decision checkpoint; was it approved?`);
@@ -0,0 +1,87 @@
1
+ import { DAG_AGENT_RUNTIME_PI_ONLY, DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1, DAG_RUNTIME_CONTRACT_SCHEMA_VERSION, } from "./types.js";
2
+ export const DAG_CONTROLLER_CAPABILITIES = {
3
+ schemaVersion: DAG_RUNTIME_CONTRACT_SCHEMA_VERSION,
4
+ agentRuntime: DAG_AGENT_RUNTIME_PI_ONLY,
5
+ repairWriterProtocol: DAG_REPAIR_WRITER_PROTOCOL_EXPLICIT_NODE_V1,
6
+ };
7
+ function parseSemver(value) {
8
+ const match = value.match(/^(\d+)\.(\d+)\.(\d+)/);
9
+ if (!match)
10
+ return undefined;
11
+ return {
12
+ major: Number(match[1]),
13
+ minor: Number(match[2]),
14
+ patch: Number(match[3]),
15
+ };
16
+ }
17
+ /** Returns true when `actual` is greater than or equal to `required`. */
18
+ export function semverGte(actual, required) {
19
+ const a = parseSemver(actual);
20
+ const r = parseSemver(required);
21
+ if (!a || !r)
22
+ return false;
23
+ if (a.major !== r.major)
24
+ return a.major > r.major;
25
+ if (a.minor !== r.minor)
26
+ return a.minor > r.minor;
27
+ return a.patch >= r.patch;
28
+ }
29
+ /**
30
+ * Decide whether a controller can run a DagSpec's declared runtimeContract.
31
+ *
32
+ * Capability fields are authoritative: an unsupported agentRuntime or
33
+ * repairWriterProtocol fails closed regardless of version. minimumControllerVersion
34
+ * is only consulted for diagnosis when the controller version is known and the
35
+ * capabilities otherwise match.
36
+ *
37
+ * Legacy DagSpecs without a runtimeContract are always compatible (they predate
38
+ * the capability handshake and rely on runtime-side derivation).
39
+ */
40
+ export function checkRuntimeContractCompatible(contract, capabilities = DAG_CONTROLLER_CAPABILITIES) {
41
+ if (!contract)
42
+ return { ok: true };
43
+ if (contract.schemaVersion !== capabilities.schemaVersion) {
44
+ return {
45
+ ok: false,
46
+ reason: `runtime contract schemaVersion ${contract.schemaVersion} is not supported by this controller (supports ${capabilities.schemaVersion}); regenerate the DAG or upgrade the controller`,
47
+ };
48
+ }
49
+ if (contract.agentRuntime !== capabilities.agentRuntime) {
50
+ return {
51
+ ok: false,
52
+ reason: `runtime contract requires agentRuntime "${contract.agentRuntime}" but this controller advertises "${capabilities.agentRuntime}"; upgrade the controller or regenerate the DAG`,
53
+ };
54
+ }
55
+ if (contract.repairWriterProtocol !== capabilities.repairWriterProtocol) {
56
+ return {
57
+ ok: false,
58
+ reason: `runtime contract requires repairWriterProtocol "${contract.repairWriterProtocol}" but this controller advertises "${capabilities.repairWriterProtocol}"; upgrade the controller or regenerate the DAG`,
59
+ };
60
+ }
61
+ if (contract.minimumControllerVersion &&
62
+ !capabilities.controllerVersion) {
63
+ return {
64
+ ok: false,
65
+ reason: `runtime contract requires controller version >= ${contract.minimumControllerVersion} but the running controller version could not be resolved; refuse to continue unpinned`,
66
+ };
67
+ }
68
+ if (contract.minimumControllerVersion &&
69
+ capabilities.controllerVersion &&
70
+ !semverGte(capabilities.controllerVersion, contract.minimumControllerVersion)) {
71
+ return {
72
+ ok: false,
73
+ reason: `runtime contract requires controller version >= ${contract.minimumControllerVersion} but this controller reports ${capabilities.controllerVersion}; upgrade the controller`,
74
+ };
75
+ }
76
+ return { ok: true };
77
+ }
78
+ /**
79
+ * Fail closed before any node execution when the DagSpec's runtimeContract is
80
+ * incompatible with this controller.
81
+ */
82
+ export function assertRuntimeContractCompatible(spec, capabilities = DAG_CONTROLLER_CAPABILITIES) {
83
+ const result = checkRuntimeContractCompatible(spec.runtimeContract, capabilities);
84
+ if (!result.ok) {
85
+ throw new Error(`incompatible DAG runtime contract: ${result.reason}`);
86
+ }
87
+ }
@@ -337,6 +337,8 @@ export async function createSkillSnapshot(input) {
337
337
  includeLearnedPatterns: request.includeLearnedPatterns,
338
338
  perSkillMaxChars: request.perSkillMaxChars,
339
339
  totalMaxChars: request.totalMaxChars,
340
+ ...(input.homeDir !== undefined ? { homeDir: input.homeDir } : {}),
341
+ ...(input.env !== undefined ? { env: input.env } : {}),
340
342
  });
341
343
  profiles.push({ ...request, resolvedInstructions });
342
344
  }