@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
@@ -1,20 +1,42 @@
1
1
  import { z } from "zod";
2
2
  import { readFile, readdir } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { getRunsJsonlPath, getTaskPoolRoot } from "./run-store.js";
4
+ import { getRunsJsonlPath, getStatesRoot, } from "./run-store.js";
5
+ import { TASK_POOL_STATE_ERROR_CODES, } from "./types.js";
5
6
  export const taskPoolRunFactSchema = z.object({
6
- schemaVersion: z.literal(1), batchRunId: z.string().min(1), workerRunId: z.string().min(1), taskId: z.string().min(1), featureId: z.string().min(1),
7
- status: z.enum(["succeeded", "failed", "run-error"]), recordedAt: z.string().datetime(),
7
+ schemaVersion: z.literal(1),
8
+ batchRunId: z.string().min(1),
9
+ workerRunId: z.string().min(1),
10
+ taskId: z.string().min(1),
11
+ featureId: z.string().min(1),
12
+ status: z.enum(["succeeded", "failed", "run-error"]),
13
+ recordedAt: z.string().datetime(),
8
14
  }).passthrough();
9
15
  export const taskPoolStateFactSchema = z.object({
10
- taskId: z.string().min(1), status: z.enum(["Draft", "Ready", "Queued", "Running", "AgentCompleted", "VerificationRunning", "HumanReview", "Done", "Failed", "Blocked", "Abandoned"]), updatedAt: z.string().datetime(),
16
+ taskId: z.string().min(1),
17
+ status: z.enum([
18
+ "Draft",
19
+ "Ready",
20
+ "Queued",
21
+ "Running",
22
+ "AgentCompleted",
23
+ "VerificationRunning",
24
+ "HumanReview",
25
+ "Done",
26
+ "Failed",
27
+ "Blocked",
28
+ "Abandoned",
29
+ ]),
30
+ updatedAt: z.string().datetime(),
31
+ schemaVersion: z.union([z.literal(1), z.literal(2)]).optional(),
32
+ featureId: z.string().min(1).optional(),
11
33
  }).passthrough();
12
34
  export async function readValidTaskPoolRuns(repoRoot) {
13
35
  const records = [];
14
36
  const warnings = [];
15
37
  try {
16
38
  const raw = await readFile(getRunsJsonlPath(repoRoot), "utf-8");
17
- for (const [index, line] of raw.split(/\r?\n/).filter(Boolean).entries())
39
+ for (const [index, line] of raw.split(/\r?\n/).filter(Boolean).entries()) {
18
40
  try {
19
41
  const parsed = taskPoolRunFactSchema.safeParse(JSON.parse(line));
20
42
  if (parsed.success)
@@ -25,6 +47,7 @@ export async function readValidTaskPoolRuns(repoRoot) {
25
47
  catch {
26
48
  warnings.push(`Task Pool runs.jsonl line ${index + 1} is corrupt`);
27
49
  }
50
+ }
28
51
  }
29
52
  catch (error) {
30
53
  if (!isNotFound(error))
@@ -32,23 +55,80 @@ export async function readValidTaskPoolRuns(repoRoot) {
32
55
  }
33
56
  return { records, warnings };
34
57
  }
58
+ /**
59
+ * Valid state reader for morning-report and similar consumers.
60
+ * - v2 feature-scoped states: included when path/content identity match.
61
+ * - bare taskId collisions among v2: warning, do not overwrite records.
62
+ * - legacy flat states: warning with migration-required code; never treated as Ready facts.
63
+ */
35
64
  export async function readValidTaskPoolStates(repoRoot) {
36
65
  const records = {};
37
66
  const warnings = [];
38
- const stateDir = path.join(getTaskPoolRoot(repoRoot), "states");
67
+ const stateDir = getStatesRoot(repoRoot);
39
68
  try {
40
- for (const entry of await readdir(stateDir))
41
- if (entry.endsWith(".json"))
69
+ const entries = await readdir(stateDir, { withFileTypes: true });
70
+ for (const entry of entries) {
71
+ if (entry.isFile() && entry.name.endsWith(".json")) {
72
+ const entryPath = path.join(stateDir, entry.name);
42
73
  try {
43
- const parsed = taskPoolStateFactSchema.safeParse(JSON.parse(await readFile(path.join(stateDir, entry), "utf-8")));
44
- if (parsed.success && parsed.data.taskId === entry.slice(0, -5))
45
- records[parsed.data.taskId] = parsed.data;
46
- else
47
- warnings.push(`Task Pool state is semantically invalid: ${entry}`);
74
+ const parsed = taskPoolStateFactSchema.safeParse(JSON.parse(await readFile(entryPath, "utf-8")));
75
+ if (!parsed.success) {
76
+ warnings.push(`Task Pool state is semantically invalid: ${entry.name}`);
77
+ continue;
78
+ }
79
+ const expectedTaskId = entry.name.slice(0, -5);
80
+ if (parsed.data.taskId !== expectedTaskId) {
81
+ warnings.push(`Task Pool state is semantically invalid: ${entry.name}`);
82
+ continue;
83
+ }
84
+ warnings.push(`${TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED}: legacy state ${entry.name}`);
48
85
  }
49
86
  catch {
50
- warnings.push(`Task Pool state is corrupt: ${entry}`);
87
+ warnings.push(`Task Pool state is corrupt: ${entry.name}`);
51
88
  }
89
+ continue;
90
+ }
91
+ if (!entry.isDirectory())
92
+ continue;
93
+ const featureId = entry.name;
94
+ const featureDir = path.join(stateDir, featureId);
95
+ let featureEntries;
96
+ try {
97
+ featureEntries = await readdir(featureDir);
98
+ }
99
+ catch {
100
+ warnings.push(`Task Pool feature state directory is unreadable: ${featureId}`);
101
+ continue;
102
+ }
103
+ for (const fileName of featureEntries) {
104
+ if (!fileName.endsWith(".json"))
105
+ continue;
106
+ const relative = `${featureId}/${fileName}`;
107
+ try {
108
+ const parsed = taskPoolStateFactSchema.safeParse(JSON.parse(await readFile(path.join(featureDir, fileName), "utf-8")));
109
+ if (!parsed.success) {
110
+ warnings.push(`Task Pool state is semantically invalid: ${relative}`);
111
+ continue;
112
+ }
113
+ const expectedTaskId = fileName.slice(0, -5);
114
+ const data = parsed.data;
115
+ if (data.taskId !== expectedTaskId ||
116
+ data.featureId !== featureId ||
117
+ data.schemaVersion !== 2) {
118
+ warnings.push(`${TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH}: ${relative}`);
119
+ continue;
120
+ }
121
+ if (records[data.taskId]) {
122
+ warnings.push(`${TASK_POOL_STATE_ERROR_CODES.IDENTITY_AMBIGUOUS}: multiple features share taskId ${data.taskId}`);
123
+ continue;
124
+ }
125
+ records[data.taskId] = data;
126
+ }
127
+ catch {
128
+ warnings.push(`Task Pool state is corrupt: ${relative}`);
129
+ }
130
+ }
131
+ }
52
132
  }
53
133
  catch (error) {
54
134
  if (!isNotFound(error))
@@ -56,4 +136,9 @@ export async function readValidTaskPoolStates(repoRoot) {
56
136
  }
57
137
  return { records, warnings };
58
138
  }
59
- function isNotFound(error) { return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT"); }
139
+ function isNotFound(error) {
140
+ return Boolean(error &&
141
+ typeof error === "object" &&
142
+ "code" in error &&
143
+ error.code === "ENOENT");
144
+ }
@@ -23,7 +23,16 @@ export async function renderMorningReport(options) {
23
23
  ...(features.length === 0 ? ["- Status: no Feature Packet discovered", "- Next Action: add or locate a Feature Packet", "- Why: no shared Feature read model is available", "- Evidence: none"] : features.flatMap((feature) => {
24
24
  const why = feature.blockingItems[0]?.message ?? `${feature.summary.tasksSucceeded}/${feature.summary.tasksTotal} tasks; ${feature.summary.requiredAcCovered}/${feature.summary.requiredAcTotal} required AC`;
25
25
  const evidence = [feature.evidence.delivery, feature.evidence.closeout, feature.evidence.morningReport, feature.evidence.observeSnapshot, ...feature.blockingItems.flatMap((item) => item.evidence)].find(Boolean) ?? "none";
26
- return [`### ${feature.featureId}`, "", `- Status: ${feature.status}`, `- Next Action: ${feature.nextAction?.command ?? feature.nextAction?.label ?? "none"}`, `- Why: ${why}`, `- Evidence: ${evidence}`, ""];
26
+ const selected = feature.planning?.selected[0];
27
+ const deferred = feature.planning?.deferred ?? [];
28
+ const blocked = feature.planning?.blocked ?? [];
29
+ const deferredText = deferred.length > 0
30
+ ? deferred.map((item) => `${item.taskId} (${item.reasonCode})`).join(", ")
31
+ : "none";
32
+ const blockedText = blocked.length > 0
33
+ ? blocked.map((item) => `${item.taskId} (${item.reasonCode}${item.blockedBy.length ? `; blockedBy ${item.blockedBy.join(",")}` : ""})`).join(", ")
34
+ : "none";
35
+ return [`### ${feature.featureId}`, "", `- Status: ${feature.status}`, `- Next Task: ${selected ? `${selected.taskId} (${selected.priority})` : "none"}`, `- Deferred: ${deferredText}`, `- Blocked: ${blockedText}`, `- Next Action: ${feature.nextAction?.command ?? feature.nextAction?.label ?? "none"}`, `- Why: ${why}`, `- Evidence: ${evidence}`, ""];
27
36
  })),
28
37
  "## Summary",
29
38
  "",
@@ -40,7 +49,8 @@ export async function renderMorningReport(options) {
40
49
  ];
41
50
  for (const run of runs) {
42
51
  const followUp = await followUpSummary(run, options.repoRoot);
43
- lines.push(`| ${run.taskId} | ${run.status} | ${run.workerRunId} | ${run.failure?.category ?? "-"} | ${followUp ?? run.failure?.derivedFollowUpTaskId ?? "-"} | ${await artifactSummary(run)} |`);
52
+ const identity = `${run.featureId}/${run.taskId}`;
53
+ lines.push(`| ${identity} | ${run.status} | ${run.workerRunId} | ${run.failure?.category ?? "-"} | ${followUp ?? run.failure?.derivedFollowUpTaskId ?? "-"} | ${await artifactSummary(run)} |`);
44
54
  }
45
55
  if (followUps > 0) {
46
56
  lines.push("", "## Human Actions", "");
@@ -4,10 +4,10 @@ import path from "node:path";
4
4
  import YAML from "yaml";
5
5
  import { controllerIdentitiesMatch, controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
6
6
  import { deriveFailureRoute, deriveFailureRouteFromError, } from "../pool/failure-routing.js";
7
- import { findRunByWorkerRunId, getTaskPoolRoot, readAllTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
7
+ import { findRunByWorkerRunId, getTaskPoolRoot, readFeatureTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
8
8
  import { runTaskSpec, } from "../run-task/run-task.js";
9
9
  import { formatDuration, noopProgressReporter, } from "../progress-reporter.js";
10
- import { computeReadyQueue } from "../task-graph/ready-queue.js";
10
+ import { planReadyTasks } from "../task-graph/ready-planner.js";
11
11
  import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
12
12
  import { taskSpecSchema } from "../task-spec/schema.js";
13
13
  import { preflightTargetRepo } from "../preflight.js";
@@ -34,15 +34,26 @@ export async function runReadyTasks(options) {
34
34
  const startedAt = now.toISOString();
35
35
  const batchRunId = options.batchRunId ?? buildBatchRunId(now);
36
36
  const graph = await loadTaskGraph(options.featureDir);
37
- const states = await readAllTaskPoolStates(options.repoRoot);
38
- const readyTaskIds = computeReadyQueue(graph, toGraphState(states)).filter((taskId) => isPoolReady(states[taskId]));
39
- const limitedTaskIds = readyTaskIds.slice(0, options.limit ?? readyTaskIds.length);
37
+ const featureId = graph.feature_id;
38
+ const states = await readFeatureTaskPoolStates(options.repoRoot, featureId);
39
+ const taskSpecs = await loadFeatureTaskSpecs(options.featureDir, graph);
40
+ const plan = planReadyTasks({
41
+ featureId,
42
+ graph,
43
+ taskSpecs,
44
+ states,
45
+ selectionLimit: options.limit ?? graph.nodes.length,
46
+ });
47
+ const limitedTaskIds = plan.selected.map((candidate) => candidate.taskId);
40
48
  const tasks = [];
41
49
  const runner = options.runTask ?? runTaskSpec;
42
50
  const progress = options.progress ?? noopProgressReporter;
43
51
  const total = limitedTaskIds.length;
44
52
  let index = 0;
45
53
  const batchStartedAt = Date.now();
54
+ const readyPlanPath = path.join(getTaskPoolRoot(options.repoRoot), "artifacts", batchRunId, "ready-plan.json");
55
+ await mkdir(path.dirname(readyPlanPath), { recursive: true });
56
+ await writeFile(readyPlanPath, `${JSON.stringify(plan, null, 2)}\n`, "utf-8");
46
57
  emit(progress, {
47
58
  type: "readyQueue.computed",
48
59
  source: "worker",
@@ -61,7 +72,10 @@ export async function runReadyTasks(options) {
61
72
  index += 1;
62
73
  const node = graph.nodes.find((candidate) => candidate.id === taskId);
63
74
  const taskSpecPath = path.join(options.featureDir, "tasks", node?.task ?? `${taskId}.yaml`);
64
- const taskSpec = await loadTaskSpec(taskSpecPath);
75
+ const taskSpec = taskSpecs.get(taskId) ?? await loadTaskSpec(taskSpecPath);
76
+ if (taskSpec.feature_id !== featureId) {
77
+ throw new Error(`task ${taskId} feature_id ${taskSpec.feature_id} does not match graph feature_id ${featureId}`);
78
+ }
65
79
  const retryOfWorkerRunId = states[taskId]?.retryOfWorkerRunId;
66
80
  let workerRunId = retryOfWorkerRunId
67
81
  ? buildRetryWorkerRunId(taskId, retryOfWorkerRunId, now)
@@ -69,14 +83,14 @@ export async function runReadyTasks(options) {
69
83
  buildStableWorkerRunId(taskId, taskSpec, now, controllerIdentity);
70
84
  if (workerRunId) {
71
85
  let existing = await findRunByWorkerRunId(options.repoRoot, workerRunId);
72
- if (existing && !controllerRunCanBeReused(existing, controllerIdentity)) {
86
+ if (existing && !runCanBeReused(existing, featureId, taskId, controllerIdentity)) {
73
87
  workerRunId = buildControllerScopedWorkerRunId(workerRunId, controllerIdentity);
74
88
  existing = await findRunByWorkerRunId(options.repoRoot, workerRunId);
75
- if (existing && !controllerRunCanBeReused(existing, controllerIdentity)) {
76
- throw new Error(`workerRunId collision has incompatible controller identity: ${workerRunId}`);
89
+ if (existing && !runCanBeReused(existing, featureId, taskId, controllerIdentity)) {
90
+ throw new Error(`workerRunId collision has incompatible feature/controller identity: ${workerRunId}`);
77
91
  }
78
92
  }
79
- if (existing) {
93
+ if (existing && runCanBeReused(existing, featureId, taskId, controllerIdentity)) {
80
94
  progress.task(`task ${index}/${total} ${taskId}: reuse existing run ${workerRunId}`);
81
95
  emit(progress, {
82
96
  type: "task.reused",
@@ -112,6 +126,8 @@ export async function runReadyTasks(options) {
112
126
  let result;
113
127
  try {
114
128
  await writeTaskPoolState(options.repoRoot, {
129
+ schemaVersion: 2,
130
+ featureId,
115
131
  taskId,
116
132
  status: "Running",
117
133
  updatedAt: new Date().toISOString(),
@@ -277,6 +293,12 @@ export async function runReadyTasks(options) {
277
293
  summary,
278
294
  tasks,
279
295
  batchRunPath,
296
+ readyPlanPath,
297
+ selectedTaskIds: limitedTaskIds,
298
+ deferredTaskIds: plan.deferred.map((candidate) => candidate.taskId),
299
+ orderingPolicy: plan.orderingPolicy,
300
+ selectionLimit: plan.selectionLimit,
301
+ effectiveConcurrency: plan.effectiveConcurrency,
280
302
  ...(controllerIdentity ? { controllerIdentity } : {}),
281
303
  };
282
304
  await mkdir(path.dirname(batchRunPath), { recursive: true });
@@ -345,7 +367,9 @@ function buildControllerScopedWorkerRunId(baseWorkerRunId, controllerIdentity) {
345
367
  .slice(0, 10);
346
368
  return `${baseWorkerRunId}-controller-${hash}`;
347
369
  }
348
- function controllerRunCanBeReused(existing, controllerIdentity) {
370
+ function runCanBeReused(existing, featureId, taskId, controllerIdentity) {
371
+ if (existing.featureId !== featureId || existing.taskId !== taskId)
372
+ return false;
349
373
  if (!existing.controllerIdentity && !controllerIdentity)
350
374
  return true;
351
375
  return controllerIdentitiesMatch(existing.controllerIdentity, controllerIdentity);
@@ -370,22 +394,13 @@ async function loadTaskGraph(featureDir) {
370
394
  async function loadTaskSpec(taskSpecPath) {
371
395
  return taskSpecSchema.parse(YAML.parse(await readFile(taskSpecPath, "utf-8")));
372
396
  }
373
- function toGraphState(states) {
374
- const graphState = {};
375
- for (const [taskId, state] of Object.entries(states)) {
376
- if (state.status === "Done")
377
- graphState[taskId] = "completed";
378
- if (state.status === "Running" || state.status === "Queued")
379
- graphState[taskId] = "running";
380
- if (state.status === "Failed")
381
- graphState[taskId] = "failed";
382
- if (state.status === "Blocked")
383
- graphState[taskId] = "blocked";
397
+ async function loadFeatureTaskSpecs(featureDir, graph) {
398
+ const specs = new Map();
399
+ for (const node of graph.nodes) {
400
+ const taskSpec = await loadTaskSpec(path.join(featureDir, "tasks", node.task ?? `${node.id}.yaml`));
401
+ specs.set(node.id, taskSpec);
384
402
  }
385
- return graphState;
386
- }
387
- function isPoolReady(state) {
388
- return !state || state.status === "Ready";
403
+ return specs;
389
404
  }
390
405
  function summarize(tasks) {
391
406
  return {
@@ -0,0 +1,136 @@
1
+ const OWN_STATUS_REASON = {
2
+ Draft: "task-draft",
3
+ Queued: "task-queued",
4
+ Running: "task-running",
5
+ AgentCompleted: "task-agent-completed",
6
+ VerificationRunning: "task-verification-running",
7
+ HumanReview: "task-human-review",
8
+ Done: "task-done",
9
+ Failed: "task-failed",
10
+ Blocked: "task-blocked",
11
+ Abandoned: "task-abandoned",
12
+ };
13
+ /** Contract severity: failed > blocked > not-completed > missing-state. */
14
+ function dependencyReason(unmet) {
15
+ if (unmet.some((item) => item.state === "Failed"))
16
+ return "dependency-failed";
17
+ if (unmet.some((item) => item.state === "Blocked"))
18
+ return "dependency-blocked";
19
+ if (unmet.some((item) => item.state !== undefined))
20
+ return "dependency-not-completed";
21
+ return "dependency-missing-state";
22
+ }
23
+ function ownReason(status) {
24
+ if (status === "Ready")
25
+ return undefined;
26
+ return OWN_STATUS_REASON[status] ?? "task-state-invalid";
27
+ }
28
+ export function planReadyTasks(input) {
29
+ if (!Number.isInteger(input.selectionLimit) || input.selectionLimit < 1) {
30
+ throw new Error("selectionLimit must be a positive integer");
31
+ }
32
+ const eligible = [];
33
+ const blocked = [];
34
+ for (const [graphIndex, node] of input.graph.nodes.entries()) {
35
+ const spec = input.taskSpecs.get(node.id);
36
+ if (!spec) {
37
+ blocked.push({
38
+ featureId: input.featureId,
39
+ taskId: node.id,
40
+ reasonCode: "task-spec-missing",
41
+ reason: "TaskSpec is missing",
42
+ blockedBy: [],
43
+ });
44
+ continue;
45
+ }
46
+ if (spec.id !== node.id || spec.type !== node.type || !same(spec.depends_on, node.depends_on)) {
47
+ blocked.push({
48
+ featureId: input.featureId,
49
+ taskId: node.id,
50
+ priority: spec.priority,
51
+ reasonCode: "task-spec-mismatch",
52
+ reason: "TaskSpec does not match task graph",
53
+ blockedBy: [],
54
+ });
55
+ continue;
56
+ }
57
+ const state = input.states[node.id];
58
+ if (state) {
59
+ const own = ownReason(state.status);
60
+ if (own) {
61
+ blocked.push({
62
+ featureId: input.featureId,
63
+ taskId: node.id,
64
+ priority: spec.priority,
65
+ reasonCode: own,
66
+ reason: own === "task-state-invalid"
67
+ ? `task status is invalid: ${String(state.status)}`
68
+ : `task status is ${state.status}`,
69
+ blockedBy: [],
70
+ currentStatus: state.status,
71
+ });
72
+ continue;
73
+ }
74
+ }
75
+ const deps = node.depends_on.map((id) => ({ id, state: input.states[id]?.status }));
76
+ const unmet = deps.filter(({ state: depState }) => depState !== "Done");
77
+ if (unmet.length) {
78
+ blocked.push({
79
+ featureId: input.featureId,
80
+ taskId: node.id,
81
+ priority: spec.priority,
82
+ reasonCode: dependencyReason(unmet),
83
+ reason: "dependencies are not completed",
84
+ blockedBy: unmet.map((item) => item.id),
85
+ });
86
+ continue;
87
+ }
88
+ eligible.push({
89
+ featureId: input.featureId,
90
+ taskId: node.id,
91
+ title: spec.title,
92
+ type: spec.type,
93
+ priority: spec.priority,
94
+ riskLevel: spec.risk_level,
95
+ graphIndex,
96
+ reason: "dependencies-satisfied",
97
+ });
98
+ }
99
+ eligible.sort((a, b) => priority(a.priority) - priority(b.priority) ||
100
+ a.graphIndex - b.graphIndex ||
101
+ ascii(a.taskId, b.taskId));
102
+ const selected = eligible.slice(0, input.selectionLimit);
103
+ const deferred = eligible.slice(input.selectionLimit).map((candidate) => ({
104
+ ...candidate,
105
+ reasonCode: "selection-limit",
106
+ selectedAhead: selected.map((item) => item.taskId),
107
+ }));
108
+ return {
109
+ schemaVersion: 1,
110
+ featureId: input.featureId,
111
+ executionMode: "serial",
112
+ effectiveConcurrency: 1,
113
+ selectionLimit: input.selectionLimit,
114
+ orderingPolicy: ["priority:P0>P1>P2>P3", "graph-order", "task-id"],
115
+ eligible,
116
+ blocked,
117
+ selected,
118
+ deferred,
119
+ summary: {
120
+ total: input.graph.nodes.length,
121
+ eligible: eligible.length,
122
+ blocked: blocked.length,
123
+ selected: selected.length,
124
+ deferred: deferred.length,
125
+ },
126
+ };
127
+ }
128
+ function priority(value) {
129
+ return { P0: 0, P1: 1, P2: 2, P3: 3 }[value];
130
+ }
131
+ function ascii(a, b) {
132
+ return a < b ? -1 : a > b ? 1 : 0;
133
+ }
134
+ function same(a, b) {
135
+ return a.length === b.length && a.every((value, index) => value === b[index]);
136
+ }
@@ -0,0 +1,104 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { controllerIdentitiesMatch, resolveRunningControllerIdentity, } from "../../shared/package-metadata.js";
6
+ export const CONTROLLER_IDENTITY_ARTIFACT_NAME = "controller-identity.json";
7
+ function hashArtifact(artifact) {
8
+ return createHash("sha256")
9
+ .update(`${JSON.stringify(artifact, null, 2)}\n`)
10
+ .digest("hex");
11
+ }
12
+ function summaryFields(identity) {
13
+ if (!identity)
14
+ return {};
15
+ return {
16
+ packageName: identity.packageName,
17
+ packageVersion: identity.packageVersion,
18
+ packageFingerprint: identity.packageFingerprint.value,
19
+ binarySha256: identity.binarySha256,
20
+ };
21
+ }
22
+ /**
23
+ * Capture the running controller identity as a run-owned fact and persist it
24
+ * under `runDir/controller-identity.json`. Returns the ref (with content hash)
25
+ * to store on DagRunState. When the identity cannot be resolved, records a
26
+ * `legacy-unpinned` artifact instead of fabricating an identity.
27
+ */
28
+ export async function captureControllerIdentity(input) {
29
+ const capturedAt = (input.now ?? new Date()).toISOString();
30
+ const identity = input.identity !== undefined
31
+ ? input.identity
32
+ : (resolveRunningControllerIdentity({
33
+ ...(input.argv ? { argv: input.argv } : {}),
34
+ ...(input.now ? { now: input.now } : {}),
35
+ }) ?? null);
36
+ const artifact = identity
37
+ ? { schemaVersion: 1, status: "pinned", capturedAt, identity }
38
+ : {
39
+ schemaVersion: 1,
40
+ status: "legacy-unpinned",
41
+ capturedAt,
42
+ identity: null,
43
+ reason: "running controller identity could not be resolved at run creation",
44
+ };
45
+ await writeJsonAtomic(path.join(input.runDir, CONTROLLER_IDENTITY_ARTIFACT_NAME), artifact);
46
+ return {
47
+ schemaVersion: 1,
48
+ path: CONTROLLER_IDENTITY_ARTIFACT_NAME,
49
+ sha256: hashArtifact(artifact),
50
+ capturedAt,
51
+ status: artifact.status,
52
+ ...summaryFields(identity),
53
+ };
54
+ }
55
+ export async function readControllerIdentityArtifact(runDir) {
56
+ try {
57
+ const raw = await readFile(path.join(runDir, CONTROLLER_IDENTITY_ARTIFACT_NAME), "utf-8");
58
+ return JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ function shortIdentity(identity) {
65
+ return `${identity.packageName}@${identity.packageVersion} (fingerprint ${identity.packageFingerprint.value}, binary ${identity.binarySha256.slice(0, 12)})`;
66
+ }
67
+ /**
68
+ * Recompute the current controller identity and compare it to the pinned run
69
+ * fact before resuming. Fails closed on drift; reports (without inventing an
70
+ * identity). Legacy/unpinned runs remain readable but cannot be resumed.
71
+ */
72
+ export async function verifyControllerIdentityForResume(input) {
73
+ const ref = input.state.controllerIdentityRef;
74
+ if (!ref || ref.status === "legacy-unpinned") {
75
+ throw new Error(`dag run ${input.state.runId} has no pinned controller identity; legacy/unpinned runs remain readable but cannot be resumed safely`);
76
+ }
77
+ const artifact = await readControllerIdentityArtifact(input.runDir);
78
+ if (!artifact) {
79
+ throw new Error(`dag run ${input.state.runId} is pinned but ${CONTROLLER_IDENTITY_ARTIFACT_NAME} is missing; refuse to resume`);
80
+ }
81
+ const persistedHash = createHash("sha256")
82
+ .update(`${JSON.stringify(artifact, null, 2)}\n`)
83
+ .digest("hex");
84
+ if (persistedHash !== ref.sha256) {
85
+ throw new Error(`dag run ${input.state.runId} controller identity artifact was tampered with (hash mismatch); refuse to resume`);
86
+ }
87
+ const pinned = artifact.identity;
88
+ if (!pinned) {
89
+ throw new Error(`dag run ${input.state.runId} is pinned but the identity artifact has no identity payload; refuse to resume`);
90
+ }
91
+ const current = input.currentIdentity !== undefined
92
+ ? input.currentIdentity
93
+ : (resolveRunningControllerIdentity({
94
+ ...(input.argv ? { argv: input.argv } : {}),
95
+ ...(input.now ? { now: input.now } : {}),
96
+ }) ?? null);
97
+ if (!current) {
98
+ throw new Error(`dag run ${input.state.runId} was pinned to ${shortIdentity(pinned)} but the current controller identity could not be resolved; refuse to resume`);
99
+ }
100
+ if (!controllerIdentitiesMatch(pinned, current)) {
101
+ throw new Error(`dag run ${input.state.runId} controller identity drifted; pinned=${shortIdentity(pinned)} current=${shortIdentity(current)}; start a new run instead of resuming`);
102
+ }
103
+ return { status: "pinned-match", identity: current };
104
+ }
@@ -2,7 +2,7 @@ import { access, appendFile, cp, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseProcessVerdict } from "../node-execution.js";
4
4
  import { freshNodeRecord } from "../dynamic-runtime/shared.js";
5
- const CONVERGENCE_CHAIN_NODE_IDS = [
5
+ const DEFAULT_CONVERGENCE_CHAIN_NODE_IDS = [
6
6
  "process-supervisor-pi",
7
7
  "process-gate-shell",
8
8
  "repair-pi",
@@ -29,7 +29,7 @@ export async function runConvergencePassController(input) {
29
29
  convergence.terminalReason = "feature-flag-off";
30
30
  return { retry: false };
31
31
  }
32
- if (!hasConvergenceChain(input.tasksById)) {
32
+ if (!hasConvergenceChain(input.tasksById, input.spec)) {
33
33
  convergence.terminalReason = "unsupported-dag-shape";
34
34
  return { retry: false };
35
35
  }
@@ -54,6 +54,7 @@ export async function runConvergencePassController(input) {
54
54
  reason: "hard-verify-failed",
55
55
  state: input.state,
56
56
  runDir: input.runDir,
57
+ spec: input.spec,
57
58
  });
58
59
  if (CONVERGENCE_NON_RETRY_FAILURES.has(hardFailure)) {
59
60
  passRecord.status = "terminal";
@@ -96,8 +97,12 @@ export async function runConvergencePassController(input) {
96
97
  await input.persistState();
97
98
  return { retry: true };
98
99
  }
99
- function hasConvergenceChain(tasksById) {
100
- return CONVERGENCE_CHAIN_NODE_IDS.every((id) => tasksById.has(id));
100
+ function getConvergenceChain(spec) {
101
+ return spec.convergence?.chainNodeIds ?? DEFAULT_CONVERGENCE_CHAIN_NODE_IDS;
102
+ }
103
+ function hasConvergenceChain(tasksById, spec) {
104
+ const chain = getConvergenceChain(spec);
105
+ return chain.every((id) => tasksById.has(id));
101
106
  }
102
107
  async function buildConvergencePassRecord(input) {
103
108
  const hardVerify = input.state.nodes["hard-verify-shell"];
@@ -122,13 +127,14 @@ async function buildConvergencePassRecord(input) {
122
127
  pass: input.pass,
123
128
  state: input.state,
124
129
  runDir: input.runDir,
130
+ spec: input.spec,
125
131
  }),
126
132
  };
127
133
  }
128
134
  async function preserveConvergencePassArtifacts(input) {
129
135
  const passDir = path.join(input.runDir, "convergence", `pass-${input.pass}`);
130
136
  await mkdir(passDir, { recursive: true });
131
- const nodeIds = nodesToPreserveForConvergence(input.state);
137
+ const nodeIds = nodesToPreserveForConvergence(input.state, input.spec);
132
138
  const refs = [];
133
139
  for (const nodeId of nodeIds) {
134
140
  const node = input.state.nodes[nodeId];
@@ -151,8 +157,9 @@ async function preserveConvergencePassArtifacts(input) {
151
157
  }
152
158
  return refs;
153
159
  }
154
- function nodesToPreserveForConvergence(state) {
155
- return CONVERGENCE_CHAIN_NODE_IDS.filter((nodeId) => state.nodes[nodeId]);
160
+ function nodesToPreserveForConvergence(state, spec) {
161
+ const chain = getConvergenceChain(spec);
162
+ return chain.filter((nodeId) => state.nodes[nodeId]);
156
163
  }
157
164
  async function copyIfExists(from, to) {
158
165
  try {
@@ -242,7 +249,8 @@ function extractSupervisorStructuredBlock(node) {
242
249
  return block;
243
250
  }
244
251
  async function resetConvergenceNodesForNextPass(input) {
245
- const resetIds = new Set(CONVERGENCE_CHAIN_NODE_IDS);
252
+ const chain = getConvergenceChain(input.spec);
253
+ const resetIds = new Set(chain);
246
254
  for (const id of collectTransitiveDescendantTaskIds(input.spec, "hard-verify-shell")) {
247
255
  const node = input.state.nodes[id];
248
256
  if (node?.status === "SKIPPED")