@tea-agent/loop-agent 0.1.0 → 0.2.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 (100) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.md +127 -92
  3. package/dist/adapters/index.js +3 -2
  4. package/dist/adapters/loop-agent.js +44 -2
  5. package/dist/application/dag/args.js +420 -0
  6. package/dist/application/dag/generate-task-dag.js +280 -0
  7. package/dist/application/dag/report-dag.js +14 -0
  8. package/dist/application/dag/run-dag.js +93 -0
  9. package/dist/application/dag/validate-dag.js +101 -0
  10. package/dist/application/loop/run-action.js +23 -0
  11. package/dist/cli/catalog.js +2 -237
  12. package/dist/cli/command-definitions.js +571 -0
  13. package/dist/cli/index.js +2 -0
  14. package/dist/cli/program.js +65 -1
  15. package/dist/cli/router.js +13 -0
  16. package/dist/cli-governance/active-residue-check.js +38 -0
  17. package/dist/commands/dag-report.js +6 -107
  18. package/dist/commands/dag-run-task.js +8 -466
  19. package/dist/commands/dag-validate.js +7 -179
  20. package/dist/commands/examples.js +90 -0
  21. package/dist/commands/init.js +1495 -0
  22. package/dist/commands/loop.js +57 -31
  23. package/dist/commands/pi-prompt.js +2 -9
  24. package/dist/commands/run-dag.js +7 -180
  25. package/dist/executors/cursor-executor-artifacts.js +3 -4
  26. package/dist/executors/cursor-worker-client.js +13 -3
  27. package/dist/executors/dag-cursor-executor.js +2 -3
  28. package/dist/executors/dag-pi-executor.js +3 -4
  29. package/dist/executors/dag-static-executor.js +2 -5
  30. package/dist/executors/pi-defaults.js +9 -0
  31. package/dist/executors/shell-executor.js +12 -20
  32. package/dist/governance/manifest-types.js +1 -0
  33. package/dist/infrastructure/harness/active-residue-policy.js +73 -0
  34. package/dist/infrastructure/harness/artifact-store.js +72 -0
  35. package/dist/infrastructure/harness/atomic-write.js +49 -0
  36. package/dist/infrastructure/harness/completed-facts-guard.js +40 -0
  37. package/dist/infrastructure/harness/loop-action-store.js +23 -0
  38. package/dist/infrastructure/harness/loop-store.js +41 -0
  39. package/dist/infrastructure/harness/one-shot-run-store.js +94 -0
  40. package/dist/infrastructure/harness/task-store.js +77 -0
  41. package/dist/records/one-shot-runs.js +26 -61
  42. package/dist/records/promotion.js +3 -4
  43. package/dist/shared/artifacts-core.js +5 -5
  44. package/dist/shared/logger.js +9 -15
  45. package/dist/task/delegate.js +4 -4
  46. package/dist/task/runtime.js +5 -7
  47. package/dist/task/state.js +6 -20
  48. package/dist/workflows/dag/convergence/controller.js +277 -0
  49. package/dist/workflows/dag/dynamic-runtime/condition.js +48 -0
  50. package/dist/workflows/dag/dynamic-runtime/loop-until.js +156 -0
  51. package/dist/workflows/dag/dynamic-runtime/map.js +185 -0
  52. package/dist/workflows/dag/dynamic-runtime/reduction.js +72 -0
  53. package/dist/workflows/dag/dynamic-runtime/shared.js +133 -0
  54. package/dist/workflows/dag/lifecycle.js +6 -5
  55. package/dist/workflows/dag/node-execution.js +262 -0
  56. package/dist/workflows/dag/run-store.js +36 -0
  57. package/dist/workflows/dag/runner.js +82 -1341
  58. package/dist/workflows/dag/scheduler.js +84 -0
  59. package/dist/workflows/dag/upstream-artifacts.js +20 -18
  60. package/dist/workflows/loop/actions/cursor-fix.js +191 -0
  61. package/dist/workflows/loop/actions/dag-action.js +130 -0
  62. package/dist/workflows/loop/actions/pi-review.js +267 -0
  63. package/dist/workflows/loop/actions/shared.js +157 -0
  64. package/dist/workflows/loop/actions/shell-verify.js +82 -0
  65. package/dist/workflows/loop/actions/types.js +1 -0
  66. package/dist/workflows/loop/actions/workflow-action.js +255 -0
  67. package/dist/workflows/loop/actions.js +55 -1212
  68. package/dist/workflows/loop/closeout.js +5 -4
  69. package/dist/workflows/loop/context.js +2 -3
  70. package/dist/workflows/loop/events.js +3 -2
  71. package/dist/workflows/loop/policy/auto-policy.js +104 -0
  72. package/dist/workflows/loop/policy/cursor-fix-policy.js +31 -0
  73. package/dist/workflows/loop/rounds.js +3 -3
  74. package/dist/workflows/loop/signals.js +4 -7
  75. package/dist/workflows/loop/state.js +11 -11
  76. package/docs/README.md +3 -2
  77. package/docs/architecture/runtime-boundaries.md +147 -0
  78. package/docs/exec-plans/active/README.md +4 -0
  79. package/docs/exec-plans/completed/README.md +6 -2
  80. package/package.json +2 -1
  81. package/skills/ai-engineering-context/SKILL.md +21 -21
  82. package/skills/loop-agent/SKILL.md +73 -188
  83. package/skills/loop-agent/references/README.md +6 -2
  84. package/skills/loop-agent/references/harness-policy.md +113 -113
  85. package/skills/loop-agent/references/learned/README.md +13 -13
  86. package/skills/loop-agent/references/long-running-loop.md +59 -0
  87. package/skills/loop-agent/references/pi-subagent-assisted-mode.md +0 -2
  88. package/skills/loop-agent/references/verification-and-failure-handling.md +18 -0
  89. package/skills/requesting-code-review/SKILL.md +40 -40
  90. package/skills/requesting-code-review/code-reviewer.md +4 -4
  91. package/skills/systematic-debugging/CREATION-LOG.md +43 -43
  92. package/skills/systematic-debugging/SKILL.md +113 -113
  93. package/skills/systematic-debugging/condition-based-waiting.md +20 -20
  94. package/skills/systematic-debugging/defense-in-depth.md +27 -27
  95. package/skills/systematic-debugging/root-cause-tracing.md +38 -38
  96. package/skills/systematic-debugging/test-academic.md +6 -6
  97. package/skills/systematic-debugging/test-pressure-1.md +6 -6
  98. package/skills/systematic-debugging/test-pressure-2.md +2 -2
  99. package/skills/systematic-debugging/test-pressure-3.md +6 -6
  100. package/skills/verification-before-completion/SKILL.md +37 -37
@@ -1,6 +1,7 @@
1
- import { access, mkdir, readFile, symlink, unlink, writeFile } from 'node:fs/promises';
1
+ import { access, mkdir, readFile, symlink, unlink } from 'node:fs/promises';
2
2
  import { spawn } from 'node:child_process';
3
3
  import path from 'node:path';
4
+ import { initializeTaskLogStubs, writeTaskConfig, } from '../infrastructure/harness/task-store.js';
4
5
  import { initializeArtifacts } from '../shared/artifacts-core.js';
5
6
  import { createWorktree } from './worktree.js';
6
7
  import { loadHarnessManifest } from '../governance/harness.js';
@@ -107,11 +108,10 @@ async function syncSourceToWorktree(repoRoot, taskId, worktreePath, executor) {
107
108
  const parsedTaskConfig = JSON.parse(rawJson);
108
109
  parsedTaskConfig.executor = executor;
109
110
  await mkdir(path.dirname(destTaskConfigPath), { recursive: true });
110
- await writeFile(destTaskConfigPath, `${JSON.stringify(parsedTaskConfig, null, 2)}\n`, 'utf-8');
111
+ await writeTaskConfig(worktreePath, taskId, parsedTaskConfig);
111
112
  await mkdir(destPaths.logsDir, { recursive: true });
112
113
  await initializeArtifacts(destTaskDir);
113
- await writeFile(path.join(destPaths.logsDir, 'executor.jsonl'), '', 'utf-8');
114
- await writeFile(path.join(destPaths.logsDir, 'decisions.jsonl'), '', 'utf-8');
114
+ await initializeTaskLogStubs(worktreePath, taskId);
115
115
  const state = await loadOrCreateWorkflowState(srcPaths.statePath, taskId, executor);
116
116
  await saveWorkflowState(destPaths.statePath, state);
117
117
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
3
- import os from 'node:os';
2
+ import { access, mkdir, readFile, readdir } from 'node:fs/promises';
4
3
  import path from 'node:path';
4
+ import { initializeTaskLogStubs, writeTaskConfig, } from '../infrastructure/harness/task-store.js';
5
5
  import { initializeArtifacts } from '../shared/artifacts-core.js';
6
6
  import { appendDecisionRecord, appendGoalEventRecord, appendWorkflowLog } from '../shared/logger.js';
7
7
  import { loadWorkflowState, saveWorkflowState } from './state.js';
@@ -163,10 +163,9 @@ export async function createTask(repoRoot, taskId, title) {
163
163
  },
164
164
  decisionLog: [],
165
165
  };
166
- await writeFile(paths.taskConfigPath, `${JSON.stringify(taskConfig, null, 2)}\n`, 'utf-8');
166
+ await writeTaskConfig(repoRoot, taskId, taskConfig);
167
167
  await saveWorkflowState(paths.statePath, state);
168
- await writeFile(path.join(paths.logsDir, 'executor.jsonl'), '', 'utf-8');
169
- await writeFile(path.join(paths.logsDir, 'decisions.jsonl'), '', 'utf-8');
168
+ await initializeTaskLogStubs(repoRoot, taskId);
170
169
  await appendWorkflowLog(paths.taskDir, `created task ${taskId}`);
171
170
  return paths.taskDir;
172
171
  }
@@ -289,8 +288,7 @@ export function getSupportedNextCommand(step, taskId, status, options) {
289
288
  return `npm run dev -- loop status ${taskId}`;
290
289
  }
291
290
  if (step === 'analyze' || step === 'plan' || step === 'spec' || step === 'implement' || step === 'verify' || step === 'retrospective') {
292
- const dagPath = path.join(os.tmpdir(), `${taskId}-dag.json`);
293
- return `npm run dev -- dag run-task ${taskId} --profile auto --strict-models --output ${JSON.stringify(dagPath)}`;
291
+ return `npm run dev -- dag run-task ${taskId} --profile auto --strict-models --output /tmp/${taskId}-dag.json`;
294
292
  }
295
293
  return `${step} step not yet implemented in this prototype; human intervention required`;
296
294
  }
@@ -1,4 +1,5 @@
1
- import { copyFile, readFile, rename, unlink, writeFile, } from 'node:fs/promises';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolveTaskStateContext, writeWorkflowState as writeTaskWorkflowState, } from '../infrastructure/harness/task-store.js';
2
3
  import { workflowStateSchema } from '../shared/types.js';
3
4
  function parseWorkflowState(raw) {
4
5
  try {
@@ -8,18 +9,6 @@ function parseWorkflowState(raw) {
8
9
  return null;
9
10
  }
10
11
  }
11
- /** Persist backup atomically (tmp → rename), tolerant of Windows rename semantics. */
12
- async function writeBackupCopy(bakPath, json) {
13
- const bakTmp = `${bakPath}.${process.pid}.tmp`;
14
- await writeFile(bakTmp, json, 'utf-8');
15
- try {
16
- await rename(bakTmp, bakPath);
17
- }
18
- catch {
19
- await copyFile(bakTmp, bakPath);
20
- await unlink(bakTmp).catch(() => { });
21
- }
22
- }
23
12
  export async function loadWorkflowState(statePath) {
24
13
  const bakPath = `${statePath}.bak`;
25
14
  let raw = '';
@@ -40,16 +29,13 @@ export async function loadWorkflowState(statePath) {
40
29
  }
41
30
  const fromBak = raw.trim() ? parseWorkflowState(raw) : null;
42
31
  if (fromBak) {
43
- const json = `${JSON.stringify(fromBak, null, 2)}\n`;
44
- await writeFile(statePath, json, 'utf-8');
45
- await writeBackupCopy(bakPath, json);
32
+ const { repoRoot, taskId } = resolveTaskStateContext(statePath);
33
+ await writeTaskWorkflowState(repoRoot, taskId, fromBak);
46
34
  return fromBak;
47
35
  }
48
36
  throw new Error(`workflow state unreadable or missing: ${statePath} (no valid ${bakPath})`);
49
37
  }
50
38
  export async function saveWorkflowState(statePath, state) {
51
- const json = `${JSON.stringify(state, null, 2)}\n`;
52
- const bakPath = `${statePath}.bak`;
53
- await writeFile(statePath, json, 'utf-8');
54
- await writeBackupCopy(bakPath, json);
39
+ const { repoRoot, taskId } = resolveTaskStateContext(statePath);
40
+ await writeTaskWorkflowState(repoRoot, taskId, state);
55
41
  }
@@ -0,0 +1,277 @@
1
+ import { access, appendFile, cp, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parseProcessVerdict } from "../node-execution.js";
4
+ import { freshNodeRecord } from "../dynamic-runtime/shared.js";
5
+ const CONVERGENCE_CHAIN_NODE_IDS = [
6
+ "process-supervisor-pi",
7
+ "process-gate-shell",
8
+ "repair-cursor",
9
+ "hard-verify-shell",
10
+ ];
11
+ const CONVERGENCE_NON_RETRY_FAILURES = new Set([
12
+ "timeout",
13
+ "spawn-error",
14
+ "write-guard",
15
+ "missing-api-key",
16
+ "auth",
17
+ "human-rejected",
18
+ "decision-gate-requires-human",
19
+ ]);
20
+ export function shouldEnableDagConvergence(spec) {
21
+ return (process.env.HARNESS_DAG_CONVERGENCE !== "off" &&
22
+ spec.convergence?.enabled === true);
23
+ }
24
+ export async function runConvergencePassController(input) {
25
+ const convergence = input.state.convergence;
26
+ if (!convergence?.enabled)
27
+ return { retry: false };
28
+ if (process.env.HARNESS_DAG_CONVERGENCE === "off") {
29
+ convergence.terminalReason = "feature-flag-off";
30
+ return { retry: false };
31
+ }
32
+ if (!hasConvergenceChain(input.tasksById)) {
33
+ convergence.terminalReason = "unsupported-dag-shape";
34
+ return { retry: false };
35
+ }
36
+ const hardVerify = input.state.nodes["hard-verify-shell"];
37
+ if (!hardVerify)
38
+ return { retry: false };
39
+ if (hardVerify.status === "FINISHED") {
40
+ convergence.terminalReason = "hard-verify-pass";
41
+ await appendConvergenceKnowledgePattern({
42
+ cwd: input.cwd,
43
+ state: input.state,
44
+ });
45
+ return { retry: false };
46
+ }
47
+ if (hardVerify.status !== "ERROR")
48
+ return { retry: false };
49
+ const currentPass = convergence.currentPass || 1;
50
+ const hardFailure = hardVerify.failureCategory ?? "unknown";
51
+ const passRecord = await buildConvergencePassRecord({
52
+ pass: currentPass,
53
+ status: "retrying",
54
+ reason: "hard-verify-failed",
55
+ state: input.state,
56
+ runDir: input.runDir,
57
+ });
58
+ if (CONVERGENCE_NON_RETRY_FAILURES.has(hardFailure)) {
59
+ passRecord.status = "terminal";
60
+ passRecord.reason = "non-retry-failure";
61
+ convergence.passHistory.push(passRecord);
62
+ convergence.terminalReason = "non-retry-failure";
63
+ await input.persistState();
64
+ return { retry: false };
65
+ }
66
+ if (input.spec.convergence?.pauseOnRegression !== false &&
67
+ detectConvergenceRegression(convergence.passHistory, passRecord)) {
68
+ passRecord.status = "paused";
69
+ passRecord.reason = "regression";
70
+ convergence.passHistory.push(passRecord);
71
+ convergence.terminalReason = "regression";
72
+ const pausedAt = new Date().toISOString();
73
+ input.state.status = "paused";
74
+ input.state.pausedAt = pausedAt;
75
+ input.state.pausedByNodeId = "hard-verify-shell";
76
+ input.state.pauseReason = "convergence-regression";
77
+ input.state.failureCategory = "convergence-regression";
78
+ await input.persistState();
79
+ return { retry: false, pausedByNodeId: "hard-verify-shell" };
80
+ }
81
+ if (currentPass >= convergence.maxPasses) {
82
+ passRecord.status = "terminal";
83
+ passRecord.reason = "max-passes";
84
+ convergence.passHistory.push(passRecord);
85
+ convergence.terminalReason = "max-passes";
86
+ await input.persistState();
87
+ return { retry: false };
88
+ }
89
+ convergence.passHistory.push(passRecord);
90
+ convergence.currentPass = currentPass + 1;
91
+ await resetConvergenceNodesForNextPass({
92
+ spec: input.spec,
93
+ state: input.state,
94
+ tasksById: input.tasksById,
95
+ });
96
+ await input.persistState();
97
+ return { retry: true };
98
+ }
99
+ function hasConvergenceChain(tasksById) {
100
+ return CONVERGENCE_CHAIN_NODE_IDS.every((id) => tasksById.has(id));
101
+ }
102
+ async function buildConvergencePassRecord(input) {
103
+ const hardVerify = input.state.nodes["hard-verify-shell"];
104
+ const processSupervisor = input.state.nodes["process-supervisor-pi"];
105
+ const verifyEvidence = hardVerify?.verifyEvidence;
106
+ return {
107
+ pass: input.pass,
108
+ startedAt: hardVerify?.startedAt,
109
+ finishedAt: new Date().toISOString(),
110
+ status: input.status,
111
+ reason: input.reason,
112
+ hardVerifyStatus: hardVerify?.status,
113
+ hardVerifyFailureCategory: hardVerify?.failureCategory,
114
+ processVerdict: parseProcessVerdict(processSupervisor),
115
+ repairArtifact: processSupervisor?.repairArtifact,
116
+ verifyPhase: verifyEvidence?.phase,
117
+ verifyQuota: verifyEvidence?.quota,
118
+ verifyCommandCount: verifyEvidence?.commandCount,
119
+ verifyCommandLabels: verifyEvidence?.commandLabels,
120
+ shellSuccessCount: countSuccessfulShellCommands(hardVerify?.stdout),
121
+ artifactRefs: await preserveConvergencePassArtifacts({
122
+ pass: input.pass,
123
+ state: input.state,
124
+ runDir: input.runDir,
125
+ }),
126
+ };
127
+ }
128
+ async function preserveConvergencePassArtifacts(input) {
129
+ const passDir = path.join(input.runDir, "convergence", `pass-${input.pass}`);
130
+ await mkdir(passDir, { recursive: true });
131
+ const nodeIds = nodesToPreserveForConvergence(input.state);
132
+ const refs = [];
133
+ for (const nodeId of nodeIds) {
134
+ const node = input.state.nodes[nodeId];
135
+ if (!node || node.status === "PENDING")
136
+ continue;
137
+ const preservedNodeRecordPath = path.join(passDir, `${nodeId}.json`);
138
+ const preservedNodeDir = path.join(passDir, nodeId);
139
+ await copyIfExists(path.join(input.runDir, `${nodeId}.json`), preservedNodeRecordPath);
140
+ await copyIfExists(path.join(input.runDir, nodeId), preservedNodeDir);
141
+ refs.push({
142
+ nodeId,
143
+ status: node.status,
144
+ failureCategory: node.failureCategory,
145
+ nodeRecordPath: node.nodeRecordPath ?? path.join(input.runDir, `${nodeId}.json`),
146
+ stdoutArtifactPath: node.stdoutArtifactPath,
147
+ assistantArtifactPath: node.assistantArtifactPath,
148
+ preservedNodeRecordPath,
149
+ preservedNodeDir,
150
+ });
151
+ }
152
+ return refs;
153
+ }
154
+ function nodesToPreserveForConvergence(state) {
155
+ return CONVERGENCE_CHAIN_NODE_IDS.filter((nodeId) => state.nodes[nodeId]);
156
+ }
157
+ async function copyIfExists(from, to) {
158
+ try {
159
+ await access(from);
160
+ await cp(from, to, { recursive: true, force: true });
161
+ }
162
+ catch {
163
+ // Missing artifacts are represented in passHistory by absent files.
164
+ }
165
+ }
166
+ function countSuccessfulShellCommands(stdout) {
167
+ if (!stdout)
168
+ return undefined;
169
+ const matches = stdout.match(/\|\s*\d+\s*\|\s*true\s*\|/g);
170
+ if (matches)
171
+ return matches.length;
172
+ if (/\|\s*\d+\s*\|\s*false\s*\|/.test(stdout))
173
+ return 0;
174
+ const explicit = stdout.match(/(?:passed|success(?:es)?|ok)\s*[=:]\s*(\d+)/i);
175
+ return explicit ? Number.parseInt(explicit[1], 10) : undefined;
176
+ }
177
+ function detectConvergenceRegression(history, current) {
178
+ const previous = [...history]
179
+ .reverse()
180
+ .find((pass) => pass.shellSuccessCount !== undefined);
181
+ if (previous?.shellSuccessCount !== undefined &&
182
+ current.shellSuccessCount !== undefined &&
183
+ current.shellSuccessCount < previous.shellSuccessCount) {
184
+ return true;
185
+ }
186
+ return isWorseFailureCategory(previous?.hardVerifyFailureCategory, current.hardVerifyFailureCategory);
187
+ }
188
+ function isWorseFailureCategory(previous, current) {
189
+ const severity = new Map([
190
+ ["success", 0],
191
+ ["nonzero-exit", 1],
192
+ ["unknown", 2],
193
+ ["timeout", 3],
194
+ ["spawn-error", 3],
195
+ ["write-guard", 4],
196
+ ]);
197
+ if (!previous || !current)
198
+ return false;
199
+ return (severity.get(current) ?? 2) > (severity.get(previous) ?? 2);
200
+ }
201
+ async function appendConvergenceKnowledgePattern(input) {
202
+ const convergence = input.state.convergence;
203
+ if (!convergence || convergence.passHistory.length === 0)
204
+ return;
205
+ const supervisor = input.state.nodes["process-supervisor-pi"];
206
+ const hardVerify = input.state.nodes["hard-verify-shell"];
207
+ const pattern = {
208
+ schemaVersion: 1,
209
+ type: "dag-convergence-repair",
210
+ runId: input.state.runId,
211
+ recordedAt: new Date().toISOString(),
212
+ passCount: convergence.currentPass,
213
+ terminalReason: convergence.terminalReason ?? "hard-verify-pass",
214
+ processVerdict: parseProcessVerdict(supervisor),
215
+ repairArtifact: supervisor?.repairArtifact,
216
+ supervisorStructuredBlock: extractSupervisorStructuredBlock(supervisor),
217
+ hardVerify: {
218
+ status: hardVerify?.status,
219
+ failureCategory: hardVerify?.failureCategory,
220
+ nodeRecordPath: hardVerify?.nodeRecordPath,
221
+ },
222
+ passHistory: convergence.passHistory.map((pass) => ({
223
+ pass: pass.pass,
224
+ reason: pass.reason,
225
+ hardVerifyFailureCategory: pass.hardVerifyFailureCategory,
226
+ shellSuccessCount: pass.shellSuccessCount,
227
+ })),
228
+ };
229
+ const knowledgeDir = path.join(input.cwd, ".harness", "knowledge");
230
+ await mkdir(knowledgeDir, { recursive: true });
231
+ await appendFile(path.join(knowledgeDir, "patterns.jsonl"), `${JSON.stringify(pattern)}\n`, "utf-8");
232
+ }
233
+ function extractSupervisorStructuredBlock(node) {
234
+ // Legacy compatibility only. New supervisor prompts must emit REPAIR_ARTIFACT_JSON.
235
+ const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
236
+ const block = {};
237
+ for (const key of ["FAILURE_CLASS", "FIX_SCOPE", "INVARIANT"]) {
238
+ const match = text.match(new RegExp(`^${key}:\\s*(.+)$`, "im"));
239
+ if (match?.[1])
240
+ block[key] = match[1].trim();
241
+ }
242
+ return block;
243
+ }
244
+ async function resetConvergenceNodesForNextPass(input) {
245
+ const resetIds = new Set(CONVERGENCE_CHAIN_NODE_IDS);
246
+ for (const id of collectTransitiveDescendantTaskIds(input.spec, "hard-verify-shell")) {
247
+ const node = input.state.nodes[id];
248
+ if (node?.status === "SKIPPED")
249
+ resetIds.add(id);
250
+ }
251
+ for (const id of resetIds) {
252
+ const task = input.tasksById.get(id);
253
+ if (!task)
254
+ continue;
255
+ input.state.nodes[id] = freshNodeRecord(task);
256
+ }
257
+ }
258
+ function collectTransitiveDescendantTaskIds(spec, rootNodeId) {
259
+ const childrenByParent = new Map();
260
+ for (const task of spec.tasks) {
261
+ for (const parent of task.depends_on) {
262
+ const children = childrenByParent.get(parent) ?? [];
263
+ children.push(task.id);
264
+ childrenByParent.set(parent, children);
265
+ }
266
+ }
267
+ const descendants = new Set();
268
+ const queue = [...(childrenByParent.get(rootNodeId) ?? [])];
269
+ while (queue.length > 0) {
270
+ const id = queue.shift();
271
+ if (descendants.has(id))
272
+ continue;
273
+ descendants.add(id);
274
+ queue.push(...(childrenByParent.get(id) ?? []));
275
+ }
276
+ return descendants;
277
+ }
@@ -0,0 +1,48 @@
1
+ import { parseConditionLiteral, resolveOutputSelector } from "./shared.js";
2
+ function evaluateConditionExpression(expression, state) {
3
+ const equality = expression.match(/^\s*(\$\..+?)\s*==\s*(.+?)\s*$/);
4
+ if (!equality?.[1]) {
5
+ throw new Error(`unsupported condition expression: ${expression}`);
6
+ }
7
+ const actual = resolveOutputSelector(equality[1], state);
8
+ const expected = parseConditionLiteral(equality[2] ?? "");
9
+ return actual === expected;
10
+ }
11
+ export function executeDynamicCondition(input) {
12
+ const started = Date.now();
13
+ const matchedIndex = input.condition.cases.findIndex((conditionCase) => evaluateConditionExpression(conditionCase.when, input.state));
14
+ const selected = matchedIndex >= 0
15
+ ? input.condition.cases[matchedIndex]?.then
16
+ : input.condition.default;
17
+ if (!selected) {
18
+ throw new Error(`condition ${input.task.id} did not match and has no default`);
19
+ }
20
+ if (!input.tasksById.has(selected)) {
21
+ throw new Error(`condition ${input.task.id} selected missing target ${selected}`);
22
+ }
23
+ const branchTargets = new Set([
24
+ ...input.condition.cases.map((conditionCase) => conditionCase.then),
25
+ ...(input.condition.default ? [input.condition.default] : []),
26
+ ]);
27
+ for (const target of branchTargets) {
28
+ if (target === selected)
29
+ continue;
30
+ const record = input.state.nodes[target];
31
+ if (record?.status === "PENDING") {
32
+ record.status = "SKIPPED";
33
+ record.skippedReason = `condition ${input.task.id} selected ${selected}; branch not selected`;
34
+ }
35
+ }
36
+ return {
37
+ ok: true,
38
+ stdout: JSON.stringify({
39
+ workflowNodeId: input.condition.workflowNodeId,
40
+ selected,
41
+ matchedCaseIndex: matchedIndex >= 0 ? matchedIndex : undefined,
42
+ defaulted: matchedIndex < 0,
43
+ }),
44
+ stderr: "",
45
+ failureCategory: "success",
46
+ durationMs: Date.now() - started,
47
+ };
48
+ }
@@ -0,0 +1,156 @@
1
+ import { writeDagRunJsonArtifact } from "../../../infrastructure/harness/artifact-store.js";
2
+ import { executeDagNode, } from "../node-execution.js";
3
+ import { writeRunSpec } from "../run-store.js";
4
+ import { freshNodeRecord, lookupPath, parseConditionLiteral, parseJsonFromText, } from "./shared.js";
5
+ function evaluateLoopStopExpression(input) {
6
+ const equality = input.expression.match(/^\s*(\$\..+?)\s*==\s*(.+?)\s*$/);
7
+ if (!equality?.[1]) {
8
+ throw new Error(`unsupported loop_until stop expression: ${input.expression}`);
9
+ }
10
+ const selector = equality[1];
11
+ let actual;
12
+ if (selector.startsWith("$.last.output.")) {
13
+ actual = lookupPath(input.lastOutput, selector.slice("$.last.output.".length));
14
+ }
15
+ else if (selector === "$.iteration") {
16
+ actual = input.iteration;
17
+ }
18
+ else {
19
+ throw new Error(`unsupported loop_until stop selector: ${selector}`);
20
+ }
21
+ const expected = parseConditionLiteral(equality[2] ?? "");
22
+ return actual === expected;
23
+ }
24
+ function renderLoopTemplate(template, iteration) {
25
+ const iterationStatus = iteration >= 2 ? "passed" : "failed";
26
+ return template
27
+ .replace(/\{\{\s*iteration\s*\}\}/g, String(iteration))
28
+ .replace(/\{\{\s*iterationStatus\s*\}\}/g, iterationStatus);
29
+ }
30
+ function buildLoopBodyChildTask(input) {
31
+ const { parent, bodyTask, iteration, nodeId, bodyIdMap } = input;
32
+ const mappedDepends = bodyTask.dependsOn.map((depId) => bodyIdMap.get(depId) ?? depId);
33
+ return {
34
+ id: nodeId,
35
+ depends_on: mappedDepends.length > 0 ? mappedDepends : parent.depends_on,
36
+ complexity: bodyTask.complexity,
37
+ subtask_prompt: renderLoopTemplate(bodyTask.subtaskPromptTemplate, iteration),
38
+ executor: bodyTask.executor,
39
+ role: bodyTask.role,
40
+ writePolicy: bodyTask.writePolicy,
41
+ allowedPaths: bodyTask.allowedPaths,
42
+ forbiddenPaths: bodyTask.forbiddenPaths,
43
+ writeSet: bodyTask.writeSet,
44
+ outputContract: bodyTask.outputContract,
45
+ static: bodyTask.executor === "static"
46
+ ? {
47
+ resultMarkdown: bodyTask.staticResultTemplate
48
+ ? renderLoopTemplate(bodyTask.staticResultTemplate, iteration)
49
+ : "dynamic loop child completed",
50
+ }
51
+ : undefined,
52
+ };
53
+ }
54
+ export async function executeDynamicLoopUntil(input) {
55
+ const started = Date.now();
56
+ const executedChildren = [];
57
+ let stopped = false;
58
+ let stopReason = "";
59
+ let iterations = 0;
60
+ for (let iteration = 1; iteration <= input.loop.maxIterations; iteration += 1) {
61
+ iterations = iteration;
62
+ const bodyIdMap = new Map(input.loop.bodyTasks.map((bodyTask) => [
63
+ bodyTask.id,
64
+ `${input.task.id}-r${iteration}-${bodyTask.id}`,
65
+ ]));
66
+ const children = input.loop.bodyTasks.map((bodyTask) => buildLoopBodyChildTask({
67
+ parent: input.task,
68
+ loop: input.loop,
69
+ bodyTask,
70
+ iteration,
71
+ nodeId: bodyIdMap.get(bodyTask.id),
72
+ bodyIdMap,
73
+ }));
74
+ for (const child of children) {
75
+ if (!input.tasksById.has(child.id)) {
76
+ input.tasksById.set(child.id, child);
77
+ input.spec.tasks.push(child);
78
+ }
79
+ if (!input.state.nodes[child.id]) {
80
+ input.state.nodes[child.id] = freshNodeRecord(child);
81
+ }
82
+ }
83
+ input.state.ranks.push(children.map((child) => child.id));
84
+ await writeRunSpec(input.runDir, input.spec);
85
+ await input.persistState();
86
+ for (const child of children) {
87
+ await executeDagNode({
88
+ nodeId: child.id,
89
+ tasksById: input.tasksById,
90
+ state: input.state,
91
+ spec: input.spec,
92
+ cwd: input.cwd,
93
+ runDir: input.runDir,
94
+ executeNode: input.executeNode,
95
+ executeDynamicNode: input.executeDynamicNode,
96
+ observer: input.observer,
97
+ persistState: input.persistState,
98
+ onPause: () => {
99
+ throw new Error(`dynamic child node ${child.id} requested a human pause; loop_until children do not support pause in v0`);
100
+ },
101
+ });
102
+ executedChildren.push(child.id);
103
+ }
104
+ const failedChild = children.find((child) => input.state.nodes[child.id]?.status !== "FINISHED");
105
+ if (failedChild) {
106
+ return {
107
+ ok: false,
108
+ stdout: JSON.stringify({
109
+ workflowNodeId: input.loop.workflowNodeId,
110
+ iterations,
111
+ stopped: false,
112
+ children: executedChildren,
113
+ }),
114
+ stderr: `loop_until child failed: ${failedChild.id}`,
115
+ failureCategory: "dynamic-loop-child-failed",
116
+ durationMs: Date.now() - started,
117
+ };
118
+ }
119
+ const lastChild = children[children.length - 1];
120
+ const lastOutput = parseJsonFromText(input.state.nodes[lastChild.id]?.stdout);
121
+ const matched = input.loop.stopWhenAny.find((expression) => evaluateLoopStopExpression({
122
+ expression,
123
+ lastOutput,
124
+ iteration,
125
+ }));
126
+ if (matched) {
127
+ stopped = true;
128
+ stopReason = matched;
129
+ break;
130
+ }
131
+ }
132
+ const manifest = {
133
+ workflowNodeId: input.loop.workflowNodeId,
134
+ expandedAt: new Date().toISOString(),
135
+ maxIterations: input.loop.maxIterations,
136
+ iterations,
137
+ stopped,
138
+ stopReason: stopped ? stopReason : undefined,
139
+ children: executedChildren.map((nodeId) => ({ nodeId })),
140
+ };
141
+ await writeDagRunJsonArtifact(input.runDir, `expansions/${input.task.id}.expansion.json`, manifest);
142
+ return {
143
+ ok: true,
144
+ stdout: JSON.stringify({
145
+ workflowNodeId: input.loop.workflowNodeId,
146
+ iterations,
147
+ stopped,
148
+ stopReason: stopped ? stopReason : undefined,
149
+ maxIterationsReached: !stopped,
150
+ children: executedChildren,
151
+ }),
152
+ stderr: "",
153
+ failureCategory: "success",
154
+ durationMs: Date.now() - started,
155
+ };
156
+ }