@tea-agent/loop-agent 0.1.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.
- package/AGENTS.md +121 -0
- package/CHANGELOG.md +37 -0
- package/README.md +144 -0
- package/bin/loop-agent.js +21 -0
- package/dist/adapters/aimax.js +91 -0
- package/dist/adapters/context.js +32 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/loop-agent.js +98 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli/catalog.js +259 -0
- package/dist/cli/help.js +55 -0
- package/dist/cli/index.js +3 -0
- package/dist/cli/program.js +505 -0
- package/dist/cli.js +12 -0
- package/dist/commands/closeout.js +13 -0
- package/dist/commands/coverage-audit.js +14 -0
- package/dist/commands/cursor-prompt.js +222 -0
- package/dist/commands/cursor-worker.js +43 -0
- package/dist/commands/dag-approve.js +102 -0
- package/dist/commands/dag-final-verification.js +76 -0
- package/dist/commands/dag-init-hybrid.js +56 -0
- package/dist/commands/dag-reconcile-tasks.js +51 -0
- package/dist/commands/dag-reject.js +91 -0
- package/dist/commands/dag-report.js +177 -0
- package/dist/commands/dag-resume.js +34 -0
- package/dist/commands/dag-run-task.js +470 -0
- package/dist/commands/dag-validate.js +186 -0
- package/dist/commands/dag-workflow-compile.js +91 -0
- package/dist/commands/dag-workflow-plan.js +130 -0
- package/dist/commands/dag-workflow-validate.js +66 -0
- package/dist/commands/delegate.js +132 -0
- package/dist/commands/docs-archive.js +5 -0
- package/dist/commands/docs-audit.js +5 -0
- package/dist/commands/doctor.js +50 -0
- package/dist/commands/goal.js +92 -0
- package/dist/commands/handoff-check.js +5 -0
- package/dist/commands/harvest.js +44 -0
- package/dist/commands/inspect.js +11 -0
- package/dist/commands/instructions.js +195 -0
- package/dist/commands/knowledge.js +64 -0
- package/dist/commands/loop-benchmark.js +72 -0
- package/dist/commands/loop.js +241 -0
- package/dist/commands/new-task.js +5 -0
- package/dist/commands/pi-prompt.js +181 -0
- package/dist/commands/pi-reuse-benchmark.js +153 -0
- package/dist/commands/plan-list.js +5 -0
- package/dist/commands/promote-run.js +29 -0
- package/dist/commands/reference-index.js +16 -0
- package/dist/commands/run-dag.js +184 -0
- package/dist/commands/spine.js +38 -0
- package/dist/commands/stats.js +84 -0
- package/dist/commands/status.js +56 -0
- package/dist/commands/study-init.js +192 -0
- package/dist/commands/workflow.js +259 -0
- package/dist/commands/worktree-create.js +31 -0
- package/dist/commands/worktree-list.js +5 -0
- package/dist/commands/worktree-remove.js +26 -0
- package/dist/cursor-worker-entry.js +8 -0
- package/dist/executors/config-core.js +55 -0
- package/dist/executors/config.js +2 -0
- package/dist/executors/cursor-artifacts.js +33 -0
- package/dist/executors/cursor-execution-log.js +81 -0
- package/dist/executors/cursor-executor-artifacts.js +135 -0
- package/dist/executors/cursor-executor.js +468 -0
- package/dist/executors/cursor-run.js +115 -0
- package/dist/executors/cursor-tool.js +94 -0
- package/dist/executors/cursor-worker-client.js +213 -0
- package/dist/executors/cursor-worker-protocol.js +18 -0
- package/dist/executors/cursor-worker-server.js +54 -0
- package/dist/executors/cursor-worker.js +3 -0
- package/dist/executors/cursor.js +6 -0
- package/dist/executors/dag-cursor-executor.js +88 -0
- package/dist/executors/dag-pi-executor.js +322 -0
- package/dist/executors/dag-static-executor.js +45 -0
- package/dist/executors/dag.js +4 -0
- package/dist/executors/index.js +8 -0
- package/dist/executors/model-routing.js +60 -0
- package/dist/executors/pi-event-serializer.js +43 -0
- package/dist/executors/pi-executor.js +606 -0
- package/dist/executors/pi-reuse-benchmark.js +316 -0
- package/dist/executors/pi-runtime-reuse.js +29 -0
- package/dist/executors/pi-sdk-executor.js +255 -0
- package/dist/executors/pi-sdk.js +1 -0
- package/dist/executors/pi.js +3 -0
- package/dist/executors/shell-executor.js +300 -0
- package/dist/executors/shell-presets.js +47 -0
- package/dist/executors/shell-verification.js +251 -0
- package/dist/executors/shell-write-guard.js +126 -0
- package/dist/executors/shell.js +3 -0
- package/dist/executors/static.js +1 -0
- package/dist/governance/checks.js +434 -0
- package/dist/governance/harness.js +9 -0
- package/dist/governance/index.js +3 -0
- package/dist/governance/manifest-types.js +128 -0
- package/dist/governance/manifest.js +2 -0
- package/dist/governance/path-guard.js +69 -0
- package/dist/governance/path-guards.js +2 -0
- package/dist/governance/profiles.js +3 -0
- package/dist/governance/requirement-coverage.js +425 -0
- package/dist/governance/skill-safety.js +135 -0
- package/dist/governance/spine-audit.js +152 -0
- package/dist/records/closeout.js +2 -0
- package/dist/records/harvest.js +236 -0
- package/dist/records/index.js +3 -0
- package/dist/records/one-shot-runs.js +421 -0
- package/dist/records/promotion.js +199 -0
- package/dist/shared/artifacts-core.js +88 -0
- package/dist/shared/artifacts.js +2 -0
- package/dist/shared/context-files.js +32 -0
- package/dist/shared/context.js +2 -0
- package/dist/shared/copy-dir.js +17 -0
- package/dist/shared/git-progress.js +165 -0
- package/dist/shared/index.js +5 -0
- package/dist/shared/logger.js +23 -0
- package/dist/shared/one-shot-prompt-args.js +98 -0
- package/dist/shared/path-refs.js +31 -0
- package/dist/shared/prompts.js +26 -0
- package/dist/shared/reference-context.js +238 -0
- package/dist/shared/timeout-policy.js +19 -0
- package/dist/shared/timeout.js +1 -0
- package/dist/shared/types.js +5 -0
- package/dist/task/config-types.js +97 -0
- package/dist/task/config.js +2 -0
- package/dist/task/delegate.js +220 -0
- package/dist/task/goal-audit.js +51 -0
- package/dist/task/goal-policy.js +8 -0
- package/dist/task/goal.js +3 -0
- package/dist/task/ids.js +1 -0
- package/dist/task/index.js +9 -0
- package/dist/task/lifecycle.js +1 -0
- package/dist/task/paths.js +1 -0
- package/dist/task/read-model.js +149 -0
- package/dist/task/runtime.js +699 -0
- package/dist/task/source-state.js +1 -0
- package/dist/task/state.js +55 -0
- package/dist/task/subagent-guidance.js +1 -0
- package/dist/task/workflow-state-types.js +92 -0
- package/dist/task/worktree-cleanup.js +140 -0
- package/dist/task/worktree.js +171 -0
- package/dist/workflows/dag/authoring.js +8 -0
- package/dist/workflows/dag/authority-surface.js +138 -0
- package/dist/workflows/dag/canvas-observer.js +474 -0
- package/dist/workflows/dag/decision-envelope.js +502 -0
- package/dist/workflows/dag/decision-evidence.js +153 -0
- package/dist/workflows/dag/decision-gates.js +1 -0
- package/dist/workflows/dag/executor-registry.js +25 -0
- package/dist/workflows/dag/facts.js +4 -0
- package/dist/workflows/dag/failure-category.js +111 -0
- package/dist/workflows/dag/final-verification.js +180 -0
- package/dist/workflows/dag/governance-constants.js +5 -0
- package/dist/workflows/dag/governance-profile.js +405 -0
- package/dist/workflows/dag/index.js +6 -0
- package/dist/workflows/dag/init-hybrid.js +855 -0
- package/dist/workflows/dag/knowledge-curator.js +162 -0
- package/dist/workflows/dag/lifecycle.js +484 -0
- package/dist/workflows/dag/prompt-source.js +88 -0
- package/dist/workflows/dag/prompt.js +130 -0
- package/dist/workflows/dag/reconcile-tasks.js +404 -0
- package/dist/workflows/dag/recovery-recommendation.js +226 -0
- package/dist/workflows/dag/repair-artifact.js +136 -0
- package/dist/workflows/dag/report.js +1019 -0
- package/dist/workflows/dag/runner.js +1677 -0
- package/dist/workflows/dag/runtime.js +5 -0
- package/dist/workflows/dag/skill-instructions.js +471 -0
- package/dist/workflows/dag/skills.js +41 -0
- package/dist/workflows/dag/spec.js +3 -0
- package/dist/workflows/dag/topo.js +30 -0
- package/dist/workflows/dag/types.js +275 -0
- package/dist/workflows/dag/upstream-artifacts.js +95 -0
- package/dist/workflows/dag/validate.js +527 -0
- package/dist/workflows/dynamic/artifacts.js +65 -0
- package/dist/workflows/dynamic/compile.js +360 -0
- package/dist/workflows/dynamic/compileTypes.js +1 -0
- package/dist/workflows/dynamic/errors.js +5 -0
- package/dist/workflows/dynamic/index.js +7 -0
- package/dist/workflows/dynamic/profiles.js +156 -0
- package/dist/workflows/dynamic/spec.js +114 -0
- package/dist/workflows/dynamic/validate.js +275 -0
- package/dist/workflows/loop/actions.js +1334 -0
- package/dist/workflows/loop/benchmark.js +510 -0
- package/dist/workflows/loop/closeout.js +134 -0
- package/dist/workflows/loop/context.js +48 -0
- package/dist/workflows/loop/events.js +25 -0
- package/dist/workflows/loop/hash.js +32 -0
- package/dist/workflows/loop/index.js +8 -0
- package/dist/workflows/loop/paths.js +17 -0
- package/dist/workflows/loop/rounds.js +81 -0
- package/dist/workflows/loop/signals.js +55 -0
- package/dist/workflows/loop/state.js +116 -0
- package/dist/workflows/loop/templates.js +54 -0
- package/dist/workflows/loop/types.js +28 -0
- package/docs/README.md +62 -0
- package/docs/agent-dag-recovery-playbook.md +158 -0
- package/docs/agent-dag-runner.md +40 -0
- package/docs/cursor-executor-usage.md +25 -0
- package/docs/decisions/README.md +3 -0
- package/docs/design/README.md +36 -0
- package/docs/development-principles.md +71 -0
- package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -0
- package/docs/exec-plans/README.md +6 -0
- package/docs/exec-plans/active/README.md +5 -0
- package/docs/exec-plans/completed/README.md +5 -0
- package/docs/feature-workflow.md +184 -0
- package/docs/harness-methodology-debugging.md +153 -0
- package/docs/harness-methodology-tdd.md +130 -0
- package/docs/harness-methodology-verification.md +27 -0
- package/docs/loop-agent-harness.md +42 -0
- package/docs/progress/README.md +3 -0
- package/docs/reports/README.md +3 -0
- package/docs/templates/adr.md +60 -0
- package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -0
- package/docs/templates/agent-dag-decision-envelope.schema.json +213 -0
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -0
- package/docs/templates/agent-dag-decision-gate.prompt.md +246 -0
- package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -0
- package/docs/templates/agent-dag-report.schema.json +423 -0
- package/docs/templates/agent-dag-review-verdict.prompt.md +68 -0
- package/docs/templates/agent-dag.base.json +195 -0
- package/docs/templates/agent-dag.final-verification.json +190 -0
- package/docs/templates/agent-dag.schema.json +316 -0
- package/docs/templates/agent-dag.supervised-implementation.json +500 -0
- package/docs/templates/exec-plan.md +64 -0
- package/docs/templates/feature-spec.md +53 -0
- package/docs/templates/hybrid-dag.json +193 -0
- package/docs/templates/progress-log.md +17 -0
- package/docs/templates/project-start-checklist.md +9 -0
- package/docs/templates/qa-report.md +42 -0
- package/docs/templates/sprint-contract.md +29 -0
- package/docs/verification-matrix.md +30 -0
- package/examples/decision-gate-agent-dag.json +123 -0
- package/examples/example-dag.json +51 -0
- package/examples/hybrid-loop-agent-dag.json +194 -0
- package/harness.json +92 -0
- package/package.json +61 -0
- package/skills/ai-engineering-context/SKILL.md +48 -0
- package/skills/loop-agent/SKILL.md +260 -0
- package/skills/loop-agent/references/README.md +63 -0
- package/skills/loop-agent/references/command-reference.md +315 -0
- package/skills/loop-agent/references/harness-policy.md +258 -0
- package/skills/loop-agent/references/hybrid-dag.md +216 -0
- package/skills/loop-agent/references/learned/README.md +21 -0
- package/skills/loop-agent/references/model-routing.md +36 -0
- package/skills/loop-agent/references/multi-worktree.md +54 -0
- package/skills/loop-agent/references/one-shot-runs.md +85 -0
- package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -0
- package/skills/loop-agent/references/pi-prompt.md +23 -0
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +83 -0
- package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -0
- package/skills/loop-agent/references/task-workflow.md +84 -0
- package/skills/loop-agent/references/verification-and-failure-handling.md +74 -0
- package/skills/requesting-code-review/SKILL.md +101 -0
- package/skills/requesting-code-review/code-reviewer.md +168 -0
- package/skills/systematic-debugging/CREATION-LOG.md +119 -0
- package/skills/systematic-debugging/SKILL.md +296 -0
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
- package/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/skills/systematic-debugging/find-polluter.sh +63 -0
- package/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/skills/systematic-debugging/test-academic.md +14 -0
- package/skills/systematic-debugging/test-pressure-1.md +58 -0
- package/skills/systematic-debugging/test-pressure-2.md +68 -0
- package/skills/systematic-debugging/test-pressure-3.md +69 -0
- package/skills/verification-before-completion/SKILL.md +154 -0
|
@@ -0,0 +1,1677 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, appendFile, cp, mkdir, readdir, readFile, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CANONICAL_TASK_ID_PATTERN, formatLocalCompactDate, } from "../../task/runtime.js";
|
|
5
|
+
import { recordDecisionEnvelopeForNode, shouldPauseOnHumanEscalation, isPauseOnHumanDecisionGate, writeHumanEscalationArtifacts, } from "./decision-envelope.js";
|
|
6
|
+
import { repoRelativePath } from "../../shared/path-refs.js";
|
|
7
|
+
import { getDagRunDir, isTerminalDagRunStatus, locateDagRun, readHumanApprovalArtifact, requireActiveDagRun, transferDagRunDir, writeDagRunState, } from "./lifecycle.js";
|
|
8
|
+
import { createDagNodeExecutor } from "./executor-registry.js";
|
|
9
|
+
import { executeDagCursorNode } from "../../executors/dag-cursor-executor.js";
|
|
10
|
+
import { executeDagPiNode } from "../../executors/dag-pi-executor.js";
|
|
11
|
+
import { assertValidDagSpec } from "./validate.js";
|
|
12
|
+
import { normalizeDagPromptSources } from "./prompt-source.js";
|
|
13
|
+
import { buildDagNodePromptEnvelope } from "./prompt.js";
|
|
14
|
+
import { persistLongNodeOutputArtifacts, relocateRunArtifactPaths, } from "./upstream-artifacts.js";
|
|
15
|
+
import { resolveDagSkillInstructions, skillInstructionMetadata, } from "./skill-instructions.js";
|
|
16
|
+
import { resolveDagNodeSkills } from "./skills.js";
|
|
17
|
+
import { topoSortToRanks } from "./topo.js";
|
|
18
|
+
import { parseDagSpec, resolveModelForTask, } from "./types.js";
|
|
19
|
+
import { buildVerifiedFindingsReport, findingSchema, verificationSchema, } from "../dynamic/artifacts.js";
|
|
20
|
+
import { parseRepairArtifactFromText, validateRepairArtifactScope, } from "./repair-artifact.js";
|
|
21
|
+
async function prepareDagRunDir(runDir) {
|
|
22
|
+
await rm(runDir, { recursive: true, force: true });
|
|
23
|
+
await mkdir(runDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
async function writeDagRunSpec(runDir, spec) {
|
|
26
|
+
await writeFile(path.join(runDir, "run.json"), `${JSON.stringify(spec, null, 2)}\n`, "utf-8");
|
|
27
|
+
}
|
|
28
|
+
export async function loadDagSpecFromFile(dagPath) {
|
|
29
|
+
const raw = JSON.parse(await readFile(dagPath, "utf-8"));
|
|
30
|
+
const normalized = await normalizeDagPromptSources(raw, path.dirname(dagPath));
|
|
31
|
+
return parseDagSpec(normalized);
|
|
32
|
+
}
|
|
33
|
+
export const CANONICAL_DAG_RUN_ID_PATTERN = /^\d{8}-[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
34
|
+
const TASK_ID_IN_TEXT_PATTERN = /\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*/g;
|
|
35
|
+
export function toKebabSlug(text, maxLen = 40) {
|
|
36
|
+
return (text
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
39
|
+
.replace(/^-|-$/g, "")
|
|
40
|
+
.slice(0, maxLen) || "dag");
|
|
41
|
+
}
|
|
42
|
+
export function extractTaskIdFromDagSpec(spec) {
|
|
43
|
+
if (spec.objective) {
|
|
44
|
+
const match = spec.objective.match(TASK_ID_IN_TEXT_PATTERN);
|
|
45
|
+
if (match)
|
|
46
|
+
return match[0];
|
|
47
|
+
}
|
|
48
|
+
for (const line of spec.globalConstraints ?? []) {
|
|
49
|
+
const match = line.match(TASK_ID_IN_TEXT_PATTERN);
|
|
50
|
+
if (match)
|
|
51
|
+
return match[0];
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
export function deriveDagRunSlug(spec) {
|
|
56
|
+
const taskId = extractTaskIdFromDagSpec(spec);
|
|
57
|
+
if (taskId && CANONICAL_TASK_ID_PATTERN.test(taskId)) {
|
|
58
|
+
return taskId.slice(11);
|
|
59
|
+
}
|
|
60
|
+
return toKebabSlug(spec.title);
|
|
61
|
+
}
|
|
62
|
+
export function validateDagRunIdFormat(runId) {
|
|
63
|
+
const warnings = [];
|
|
64
|
+
if (!CANONICAL_DAG_RUN_ID_PATTERN.test(runId)) {
|
|
65
|
+
warnings.push(`run-id "${runId}" does not match canonical YYYYMMDD-<slug>; legacy run facts remain readable`);
|
|
66
|
+
}
|
|
67
|
+
return { warnings };
|
|
68
|
+
}
|
|
69
|
+
async function listExistingDagRunIds(cwd) {
|
|
70
|
+
const ids = new Set();
|
|
71
|
+
for (const lifecycle of ["active", "completed", "paused"]) {
|
|
72
|
+
const dir = path.join(cwd, ".harness", "dag-runs", lifecycle);
|
|
73
|
+
try {
|
|
74
|
+
const entries = await readdir(dir);
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry !== ".gitkeep")
|
|
77
|
+
ids.add(entry);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// lifecycle dir may not exist in minimal fixtures.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return ids;
|
|
85
|
+
}
|
|
86
|
+
export function collisionSafeDagRunId(baseId, existing, now = new Date()) {
|
|
87
|
+
if (!existing.has(baseId))
|
|
88
|
+
return baseId;
|
|
89
|
+
for (let round = 2; round <= 99; round += 1) {
|
|
90
|
+
const candidate = `${baseId}-r${round}`;
|
|
91
|
+
if (!existing.has(candidate))
|
|
92
|
+
return candidate;
|
|
93
|
+
}
|
|
94
|
+
const hh = String(now.getHours()).padStart(2, "0");
|
|
95
|
+
const mm = String(now.getMinutes()).padStart(2, "0");
|
|
96
|
+
const ss = String(now.getSeconds()).padStart(2, "0");
|
|
97
|
+
const timed = `${baseId}-${hh}${mm}${ss}`;
|
|
98
|
+
if (!existing.has(timed))
|
|
99
|
+
return timed;
|
|
100
|
+
return `${timed}-r2`;
|
|
101
|
+
}
|
|
102
|
+
export async function buildDagRunId(spec, options) {
|
|
103
|
+
const now = options?.now ?? new Date();
|
|
104
|
+
const datePrefix = formatLocalCompactDate(now);
|
|
105
|
+
const baseId = `${datePrefix}-${deriveDagRunSlug(spec)}`;
|
|
106
|
+
if (!options?.cwd)
|
|
107
|
+
return baseId;
|
|
108
|
+
const existing = await listExistingDagRunIds(options.cwd);
|
|
109
|
+
return collisionSafeDagRunId(baseId, existing, now);
|
|
110
|
+
}
|
|
111
|
+
export function buildNodePrompt(spec, task, upstream) {
|
|
112
|
+
return buildDagNodePromptEnvelope({
|
|
113
|
+
spec,
|
|
114
|
+
task,
|
|
115
|
+
upstream,
|
|
116
|
+
resolvedSkills: resolveDagNodeSkills(spec, task),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export async function buildNodePromptWithResolvedSkillInstructions(spec, task, upstream, cwd) {
|
|
120
|
+
const skillNames = resolveDagNodeSkills(spec, task);
|
|
121
|
+
const resolvedSkillInstructions = task.executor === "cursor" || task.executor === "pi"
|
|
122
|
+
? await resolveDagSkillInstructions(skillNames, {
|
|
123
|
+
cwd,
|
|
124
|
+
includeLearnedPatterns: task.role === "implementer",
|
|
125
|
+
})
|
|
126
|
+
: [];
|
|
127
|
+
return {
|
|
128
|
+
prompt: buildDagNodePromptEnvelope({
|
|
129
|
+
spec,
|
|
130
|
+
task,
|
|
131
|
+
upstream,
|
|
132
|
+
resolvedSkills: skillNames,
|
|
133
|
+
resolvedSkillInstructions,
|
|
134
|
+
}),
|
|
135
|
+
resolvedSkills: skillInstructionMetadata(resolvedSkillInstructions),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function createInitialRunState(spec, opts, ranks, runId = opts.runId ?? "dag") {
|
|
139
|
+
const nodes = {};
|
|
140
|
+
for (const task of spec.tasks) {
|
|
141
|
+
nodes[task.id] = {
|
|
142
|
+
id: task.id,
|
|
143
|
+
status: "PENDING",
|
|
144
|
+
executor: task.executor,
|
|
145
|
+
complexity: task.complexity,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
version: 1,
|
|
150
|
+
title: spec.title,
|
|
151
|
+
runId,
|
|
152
|
+
cwd: opts.cwd,
|
|
153
|
+
startedAt: new Date().toISOString(),
|
|
154
|
+
status: opts.initOnly || opts.dryRun ? "pending" : "running",
|
|
155
|
+
ranks,
|
|
156
|
+
nodes,
|
|
157
|
+
...(shouldEnableDagConvergence(spec)
|
|
158
|
+
? {
|
|
159
|
+
convergence: {
|
|
160
|
+
enabled: true,
|
|
161
|
+
maxPasses: Math.max(1, spec.convergence?.maxPasses ?? 3),
|
|
162
|
+
currentPass: 1,
|
|
163
|
+
passHistory: [],
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export async function runDag(spec, opts) {
|
|
170
|
+
assertValidDagSpec(spec);
|
|
171
|
+
const { ranks } = topoSortToRanks(spec);
|
|
172
|
+
const maxConcurrent = Math.max(1, opts.maxConcurrent ?? 4);
|
|
173
|
+
let runId = opts.runId;
|
|
174
|
+
if (runId) {
|
|
175
|
+
for (const warning of validateDagRunIdFormat(runId).warnings) {
|
|
176
|
+
console.warn(`[run-dag] warning: ${warning}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
runId = await buildDagRunId(spec, { cwd: opts.cwd });
|
|
181
|
+
}
|
|
182
|
+
const state = createInitialRunState(spec, opts, ranks, runId);
|
|
183
|
+
const activeRunDir = getDagRunDir(opts.cwd, "active", state.runId);
|
|
184
|
+
const completedRunDir = getDagRunDir(opts.cwd, "completed", state.runId);
|
|
185
|
+
const pausedRunDir = getDagRunDir(opts.cwd, "paused", state.runId);
|
|
186
|
+
const runDir = activeRunDir;
|
|
187
|
+
await prepareDagRunDir(runDir);
|
|
188
|
+
await writeDagRunSpec(runDir, spec);
|
|
189
|
+
await writeDagRunState(runDir, state);
|
|
190
|
+
await notifyRunObserver(opts.observer, "onRunStart", state);
|
|
191
|
+
if (opts.dryRun || opts.initOnly) {
|
|
192
|
+
return {
|
|
193
|
+
title: spec.title,
|
|
194
|
+
runId: state.runId,
|
|
195
|
+
status: state.status,
|
|
196
|
+
ranks,
|
|
197
|
+
nodes: state.nodes,
|
|
198
|
+
runDir,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
return await executeDagCheckpoint({
|
|
203
|
+
spec,
|
|
204
|
+
state,
|
|
205
|
+
ranks,
|
|
206
|
+
runDir,
|
|
207
|
+
cwd: opts.cwd,
|
|
208
|
+
maxConcurrent,
|
|
209
|
+
executeNode: opts.executeNode,
|
|
210
|
+
observer: opts.observer,
|
|
211
|
+
activeRunDir,
|
|
212
|
+
completedRunDir,
|
|
213
|
+
pausedRunDir,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await stopCursorWorkerIfRunning();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export async function resumeDagRun(opts) {
|
|
221
|
+
const located = await locateDagRun(opts.cwd, opts.runId);
|
|
222
|
+
if (!located) {
|
|
223
|
+
throw new Error(`dag run not found: ${opts.runId}`);
|
|
224
|
+
}
|
|
225
|
+
if (located.lifecycle === "completed") {
|
|
226
|
+
throw new Error(`dag run ${opts.runId} is already completed`);
|
|
227
|
+
}
|
|
228
|
+
if (located.lifecycle === "paused") {
|
|
229
|
+
throw new Error(`dag run ${opts.runId} is paused; approve before resume`);
|
|
230
|
+
}
|
|
231
|
+
const { runDir, state, spec } = await requireActiveDagRun(opts.cwd, opts.runId);
|
|
232
|
+
if (state.status === "paused") {
|
|
233
|
+
throw new Error(`dag run ${opts.runId} is paused; approve before resume`);
|
|
234
|
+
}
|
|
235
|
+
if (isTerminalDagRunStatus(state.status)) {
|
|
236
|
+
throw new Error(`dag run ${opts.runId} is already terminal (status=${state.status})`);
|
|
237
|
+
}
|
|
238
|
+
const decisionNodeId = state.humanDecisionNodeId;
|
|
239
|
+
if (!decisionNodeId) {
|
|
240
|
+
throw new Error(`dag run ${opts.runId} has no human decision checkpoint; was it approved?`);
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
await readHumanApprovalArtifact(runDir, decisionNodeId);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
throw new Error(`dag run ${opts.runId} is missing human-approval.json for node ${decisionNodeId}`);
|
|
247
|
+
}
|
|
248
|
+
const maxConcurrent = Math.max(1, opts.maxConcurrent ?? 4);
|
|
249
|
+
state.status = "running";
|
|
250
|
+
const activeRunDir = getDagRunDir(opts.cwd, "active", state.runId);
|
|
251
|
+
const completedRunDir = getDagRunDir(opts.cwd, "completed", state.runId);
|
|
252
|
+
const pausedRunDir = getDagRunDir(opts.cwd, "paused", state.runId);
|
|
253
|
+
try {
|
|
254
|
+
return await executeDagCheckpoint({
|
|
255
|
+
spec,
|
|
256
|
+
state,
|
|
257
|
+
ranks: state.ranks,
|
|
258
|
+
runDir,
|
|
259
|
+
cwd: state.cwd,
|
|
260
|
+
maxConcurrent,
|
|
261
|
+
executeNode: opts.executeNode,
|
|
262
|
+
observer: opts.observer,
|
|
263
|
+
activeRunDir,
|
|
264
|
+
completedRunDir,
|
|
265
|
+
pausedRunDir,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
await stopCursorWorkerIfRunning();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function executeDagCheckpoint(input) {
|
|
273
|
+
const { spec, state, ranks, runDir: initialRunDir, cwd, maxConcurrent, completedRunDir, pausedRunDir, } = input;
|
|
274
|
+
let runDir = initialRunDir;
|
|
275
|
+
let stateWriteQueue = Promise.resolve();
|
|
276
|
+
const persistState = async () => {
|
|
277
|
+
stateWriteQueue = stateWriteQueue.then(() => writeDagRunState(runDir, state));
|
|
278
|
+
await stateWriteQueue;
|
|
279
|
+
};
|
|
280
|
+
const tasksById = new Map(spec.tasks.map((task) => [task.id, task]));
|
|
281
|
+
const baseExecuteNode = input.executeNode ??
|
|
282
|
+
createDagNodeExecutor({
|
|
283
|
+
runDir,
|
|
284
|
+
runId: state.runId,
|
|
285
|
+
spec,
|
|
286
|
+
});
|
|
287
|
+
let pausedByNodeId;
|
|
288
|
+
while (true) {
|
|
289
|
+
pausedByNodeId = await executeDagRanksOnce({
|
|
290
|
+
spec,
|
|
291
|
+
state,
|
|
292
|
+
ranks,
|
|
293
|
+
tasksById,
|
|
294
|
+
cwd,
|
|
295
|
+
runDir,
|
|
296
|
+
maxConcurrent,
|
|
297
|
+
baseExecuteNode,
|
|
298
|
+
customExecuteNode: input.executeNode,
|
|
299
|
+
observer: input.observer,
|
|
300
|
+
persistState,
|
|
301
|
+
});
|
|
302
|
+
if (pausedByNodeId) {
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
const convergenceDecision = await runConvergencePassController({
|
|
306
|
+
spec,
|
|
307
|
+
state,
|
|
308
|
+
tasksById,
|
|
309
|
+
runDir,
|
|
310
|
+
cwd,
|
|
311
|
+
persistState,
|
|
312
|
+
});
|
|
313
|
+
if (convergenceDecision.pausedByNodeId) {
|
|
314
|
+
pausedByNodeId = convergenceDecision.pausedByNodeId;
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
if (convergenceDecision.retry) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
state.finishedAt = new Date().toISOString();
|
|
323
|
+
const runDirBeforeTransfer = runDir;
|
|
324
|
+
if (pausedByNodeId) {
|
|
325
|
+
state.status = "paused";
|
|
326
|
+
await persistState();
|
|
327
|
+
await notifyRunObserver(input.observer, "onRunFinish", state);
|
|
328
|
+
runDir = await transferDagRunDir(runDir, pausedRunDir);
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
finalizeTerminalRunStatus(state, spec.tasks.length);
|
|
332
|
+
await persistState();
|
|
333
|
+
await notifyRunObserver(input.observer, "onRunFinish", state);
|
|
334
|
+
runDir = await transferDagRunDir(runDir, completedRunDir);
|
|
335
|
+
}
|
|
336
|
+
await relocateRunArtifactPaths({
|
|
337
|
+
runDir,
|
|
338
|
+
oldRunDir: runDirBeforeTransfer,
|
|
339
|
+
nodes: state.nodes,
|
|
340
|
+
});
|
|
341
|
+
relocateConvergenceArtifactPaths(state, runDirBeforeTransfer, runDir);
|
|
342
|
+
await persistState();
|
|
343
|
+
return {
|
|
344
|
+
title: spec.title,
|
|
345
|
+
runId: state.runId,
|
|
346
|
+
status: state.status,
|
|
347
|
+
ranks,
|
|
348
|
+
nodes: state.nodes,
|
|
349
|
+
runDir,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async function executeDagRanksOnce(input) {
|
|
353
|
+
let pausedByNodeId;
|
|
354
|
+
for (const rank of input.ranks) {
|
|
355
|
+
const runnable = rank.filter((id) => {
|
|
356
|
+
const node = input.state.nodes[id];
|
|
357
|
+
const task = input.tasksById.get(id);
|
|
358
|
+
if (!node || node.status !== "PENDING")
|
|
359
|
+
return false;
|
|
360
|
+
return !shouldSkipNode(node, task, input.state.nodes);
|
|
361
|
+
});
|
|
362
|
+
const blocked = rank.filter((id) => {
|
|
363
|
+
const node = input.state.nodes[id];
|
|
364
|
+
const task = input.tasksById.get(id);
|
|
365
|
+
return (node?.status === "PENDING" &&
|
|
366
|
+
shouldSkipNode(node, task, input.state.nodes));
|
|
367
|
+
});
|
|
368
|
+
for (const id of blocked) {
|
|
369
|
+
const node = input.state.nodes[id];
|
|
370
|
+
node.status = "SKIPPED";
|
|
371
|
+
node.skippedReason = conditionSkippedByAncestor(input.tasksById.get(id), input.state.nodes)
|
|
372
|
+
? "condition ancestor branch not selected"
|
|
373
|
+
: "upstream dependency failed or was skipped";
|
|
374
|
+
}
|
|
375
|
+
if (blocked.length > 0) {
|
|
376
|
+
await input.persistState();
|
|
377
|
+
}
|
|
378
|
+
const pauseGateRunnable = runnable.filter((id) => {
|
|
379
|
+
const task = input.tasksById.get(id);
|
|
380
|
+
return isPauseOnHumanDecisionGate(task);
|
|
381
|
+
});
|
|
382
|
+
const regularRunnable = runnable.filter((id) => !pauseGateRunnable.includes(id));
|
|
383
|
+
const rankWriteGuardedNodeIds = runnable.filter((id) => {
|
|
384
|
+
const task = input.tasksById.get(id);
|
|
385
|
+
return task ? isWriteGuardedAgentExecutor(task) : false;
|
|
386
|
+
});
|
|
387
|
+
const executeNode = buildRankAwareExecuteNode({
|
|
388
|
+
baseExecuteNode: input.baseExecuteNode,
|
|
389
|
+
customExecuteNode: input.customExecuteNode,
|
|
390
|
+
rankWriteGuardedNodeIds,
|
|
391
|
+
tasksById: input.tasksById,
|
|
392
|
+
meta: {
|
|
393
|
+
runDir: input.runDir,
|
|
394
|
+
runId: input.state.runId,
|
|
395
|
+
spec: input.spec,
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
for (const nodeId of pauseGateRunnable) {
|
|
399
|
+
await executeDagNode({
|
|
400
|
+
nodeId,
|
|
401
|
+
tasksById: input.tasksById,
|
|
402
|
+
state: input.state,
|
|
403
|
+
spec: input.spec,
|
|
404
|
+
cwd: input.cwd,
|
|
405
|
+
runDir: input.runDir,
|
|
406
|
+
executeNode,
|
|
407
|
+
observer: input.observer,
|
|
408
|
+
persistState: input.persistState,
|
|
409
|
+
onPause: (id, pausedAt, pauseReason) => {
|
|
410
|
+
pausedByNodeId = id;
|
|
411
|
+
input.state.pausedAt = pausedAt;
|
|
412
|
+
input.state.pausedByNodeId = id;
|
|
413
|
+
input.state.pauseReason = pauseReason;
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
if (pausedByNodeId)
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
if (pausedByNodeId)
|
|
420
|
+
break;
|
|
421
|
+
await mapConcurrent(regularRunnable, input.maxConcurrent, async (nodeId) => {
|
|
422
|
+
await executeDagNode({
|
|
423
|
+
nodeId,
|
|
424
|
+
tasksById: input.tasksById,
|
|
425
|
+
state: input.state,
|
|
426
|
+
spec: input.spec,
|
|
427
|
+
cwd: input.cwd,
|
|
428
|
+
runDir: input.runDir,
|
|
429
|
+
executeNode,
|
|
430
|
+
observer: input.observer,
|
|
431
|
+
persistState: input.persistState,
|
|
432
|
+
onPause: (id, pausedAt, pauseReason) => {
|
|
433
|
+
pausedByNodeId = id;
|
|
434
|
+
input.state.pausedAt = pausedAt;
|
|
435
|
+
input.state.pausedByNodeId = id;
|
|
436
|
+
input.state.pauseReason = pauseReason;
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
if (pausedByNodeId)
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
return pausedByNodeId;
|
|
444
|
+
}
|
|
445
|
+
function convergenceChainNodeIds(tasksById) {
|
|
446
|
+
const repairId = tasksById.has("repair-pi") ? "repair-pi" : "repair-cursor";
|
|
447
|
+
return [
|
|
448
|
+
"process-supervisor-pi",
|
|
449
|
+
"process-gate-shell",
|
|
450
|
+
repairId,
|
|
451
|
+
"hard-verify-shell",
|
|
452
|
+
];
|
|
453
|
+
}
|
|
454
|
+
const CONVERGENCE_NON_RETRY_FAILURES = new Set([
|
|
455
|
+
"timeout",
|
|
456
|
+
"spawn-error",
|
|
457
|
+
"write-guard",
|
|
458
|
+
"missing-api-key",
|
|
459
|
+
"auth",
|
|
460
|
+
"human-rejected",
|
|
461
|
+
"decision-gate-requires-human",
|
|
462
|
+
]);
|
|
463
|
+
function shouldEnableDagConvergence(spec) {
|
|
464
|
+
return (process.env.HARNESS_DAG_CONVERGENCE !== "off" &&
|
|
465
|
+
spec.convergence?.enabled === true);
|
|
466
|
+
}
|
|
467
|
+
async function runConvergencePassController(input) {
|
|
468
|
+
const convergence = input.state.convergence;
|
|
469
|
+
if (!convergence?.enabled)
|
|
470
|
+
return { retry: false };
|
|
471
|
+
if (process.env.HARNESS_DAG_CONVERGENCE === "off") {
|
|
472
|
+
convergence.terminalReason = "feature-flag-off";
|
|
473
|
+
return { retry: false };
|
|
474
|
+
}
|
|
475
|
+
if (!hasConvergenceChain(input.tasksById)) {
|
|
476
|
+
convergence.terminalReason = "unsupported-dag-shape";
|
|
477
|
+
return { retry: false };
|
|
478
|
+
}
|
|
479
|
+
const hardVerify = input.state.nodes["hard-verify-shell"];
|
|
480
|
+
if (!hardVerify)
|
|
481
|
+
return { retry: false };
|
|
482
|
+
if (hardVerify.status === "FINISHED") {
|
|
483
|
+
convergence.terminalReason = "hard-verify-pass";
|
|
484
|
+
await appendConvergenceKnowledgePattern({
|
|
485
|
+
cwd: input.cwd,
|
|
486
|
+
state: input.state,
|
|
487
|
+
});
|
|
488
|
+
return { retry: false };
|
|
489
|
+
}
|
|
490
|
+
if (hardVerify.status !== "ERROR")
|
|
491
|
+
return { retry: false };
|
|
492
|
+
const currentPass = convergence.currentPass || 1;
|
|
493
|
+
const hardFailure = hardVerify.failureCategory ?? "unknown";
|
|
494
|
+
const passRecord = await buildConvergencePassRecord({
|
|
495
|
+
pass: currentPass,
|
|
496
|
+
status: "retrying",
|
|
497
|
+
reason: "hard-verify-failed",
|
|
498
|
+
state: input.state,
|
|
499
|
+
runDir: input.runDir,
|
|
500
|
+
});
|
|
501
|
+
if (CONVERGENCE_NON_RETRY_FAILURES.has(hardFailure)) {
|
|
502
|
+
passRecord.status = "terminal";
|
|
503
|
+
passRecord.reason = "non-retry-failure";
|
|
504
|
+
convergence.passHistory.push(passRecord);
|
|
505
|
+
convergence.terminalReason = "non-retry-failure";
|
|
506
|
+
await input.persistState();
|
|
507
|
+
return { retry: false };
|
|
508
|
+
}
|
|
509
|
+
if (input.spec.convergence?.pauseOnRegression !== false &&
|
|
510
|
+
detectConvergenceRegression(convergence.passHistory, passRecord)) {
|
|
511
|
+
passRecord.status = "paused";
|
|
512
|
+
passRecord.reason = "regression";
|
|
513
|
+
convergence.passHistory.push(passRecord);
|
|
514
|
+
convergence.terminalReason = "regression";
|
|
515
|
+
const pausedAt = new Date().toISOString();
|
|
516
|
+
input.state.status = "paused";
|
|
517
|
+
input.state.pausedAt = pausedAt;
|
|
518
|
+
input.state.pausedByNodeId = "hard-verify-shell";
|
|
519
|
+
input.state.pauseReason = "convergence-regression";
|
|
520
|
+
input.state.failureCategory = "convergence-regression";
|
|
521
|
+
await input.persistState();
|
|
522
|
+
return { retry: false, pausedByNodeId: "hard-verify-shell" };
|
|
523
|
+
}
|
|
524
|
+
if (currentPass >= convergence.maxPasses) {
|
|
525
|
+
passRecord.status = "terminal";
|
|
526
|
+
passRecord.reason = "max-passes";
|
|
527
|
+
convergence.passHistory.push(passRecord);
|
|
528
|
+
convergence.terminalReason = "max-passes";
|
|
529
|
+
await input.persistState();
|
|
530
|
+
return { retry: false };
|
|
531
|
+
}
|
|
532
|
+
convergence.passHistory.push(passRecord);
|
|
533
|
+
convergence.currentPass = currentPass + 1;
|
|
534
|
+
await resetConvergenceNodesForNextPass({
|
|
535
|
+
spec: input.spec,
|
|
536
|
+
state: input.state,
|
|
537
|
+
tasksById: input.tasksById,
|
|
538
|
+
});
|
|
539
|
+
await input.persistState();
|
|
540
|
+
return { retry: true };
|
|
541
|
+
}
|
|
542
|
+
function hasConvergenceChain(tasksById) {
|
|
543
|
+
return convergenceChainNodeIds(tasksById).every((id) => tasksById.has(id));
|
|
544
|
+
}
|
|
545
|
+
async function buildConvergencePassRecord(input) {
|
|
546
|
+
const hardVerify = input.state.nodes["hard-verify-shell"];
|
|
547
|
+
const processSupervisor = input.state.nodes["process-supervisor-pi"];
|
|
548
|
+
const verifyEvidence = hardVerify?.verifyEvidence;
|
|
549
|
+
return {
|
|
550
|
+
pass: input.pass,
|
|
551
|
+
startedAt: hardVerify?.startedAt,
|
|
552
|
+
finishedAt: new Date().toISOString(),
|
|
553
|
+
status: input.status,
|
|
554
|
+
reason: input.reason,
|
|
555
|
+
hardVerifyStatus: hardVerify?.status,
|
|
556
|
+
hardVerifyFailureCategory: hardVerify?.failureCategory,
|
|
557
|
+
processVerdict: parseProcessVerdict(processSupervisor),
|
|
558
|
+
repairArtifact: processSupervisor?.repairArtifact,
|
|
559
|
+
verifyPhase: verifyEvidence?.phase,
|
|
560
|
+
verifyQuota: verifyEvidence?.quota,
|
|
561
|
+
verifyCommandCount: verifyEvidence?.commandCount,
|
|
562
|
+
verifyCommandLabels: verifyEvidence?.commandLabels,
|
|
563
|
+
shellSuccessCount: countSuccessfulShellCommands(hardVerify?.stdout),
|
|
564
|
+
artifactRefs: await preserveConvergencePassArtifacts({
|
|
565
|
+
pass: input.pass,
|
|
566
|
+
state: input.state,
|
|
567
|
+
runDir: input.runDir,
|
|
568
|
+
}),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
async function preserveConvergencePassArtifacts(input) {
|
|
572
|
+
const passDir = path.join(input.runDir, "convergence", `pass-${input.pass}`);
|
|
573
|
+
await mkdir(passDir, { recursive: true });
|
|
574
|
+
const nodeIds = nodesToPreserveForConvergence(input.state);
|
|
575
|
+
const refs = [];
|
|
576
|
+
for (const nodeId of nodeIds) {
|
|
577
|
+
const node = input.state.nodes[nodeId];
|
|
578
|
+
if (!node || node.status === "PENDING")
|
|
579
|
+
continue;
|
|
580
|
+
const preservedNodeRecordPath = path.join(passDir, `${nodeId}.json`);
|
|
581
|
+
const preservedNodeDir = path.join(passDir, nodeId);
|
|
582
|
+
await copyIfExists(path.join(input.runDir, `${nodeId}.json`), preservedNodeRecordPath);
|
|
583
|
+
await copyIfExists(path.join(input.runDir, nodeId), preservedNodeDir);
|
|
584
|
+
refs.push({
|
|
585
|
+
nodeId,
|
|
586
|
+
status: node.status,
|
|
587
|
+
failureCategory: node.failureCategory,
|
|
588
|
+
nodeRecordPath: node.nodeRecordPath ?? path.join(input.runDir, `${nodeId}.json`),
|
|
589
|
+
stdoutArtifactPath: node.stdoutArtifactPath,
|
|
590
|
+
assistantArtifactPath: node.assistantArtifactPath,
|
|
591
|
+
preservedNodeRecordPath,
|
|
592
|
+
preservedNodeDir,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return refs;
|
|
596
|
+
}
|
|
597
|
+
function nodesToPreserveForConvergence(state) {
|
|
598
|
+
const repairId = state.nodes["repair-pi"] ? "repair-pi" : "repair-cursor";
|
|
599
|
+
return [
|
|
600
|
+
"process-supervisor-pi",
|
|
601
|
+
"process-gate-shell",
|
|
602
|
+
repairId,
|
|
603
|
+
"hard-verify-shell",
|
|
604
|
+
].filter((nodeId) => state.nodes[nodeId]);
|
|
605
|
+
}
|
|
606
|
+
async function copyIfExists(from, to) {
|
|
607
|
+
try {
|
|
608
|
+
await access(from);
|
|
609
|
+
await cp(from, to, { recursive: true, force: true });
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
// Missing artifacts are represented in passHistory by absent files.
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
function parseProcessVerdict(node) {
|
|
616
|
+
const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
|
|
617
|
+
for (const line of text.split("\n")) {
|
|
618
|
+
const trimmed = line.trim();
|
|
619
|
+
if (trimmed === "VERDICT: pass")
|
|
620
|
+
return "pass";
|
|
621
|
+
if (trimmed === "VERDICT: request-revision")
|
|
622
|
+
return "request-revision";
|
|
623
|
+
}
|
|
624
|
+
return "unknown";
|
|
625
|
+
}
|
|
626
|
+
function assertRepairArtifactVerdictMatchesSupervisor(input) {
|
|
627
|
+
const supervisorVerdict = parseProcessVerdict(input.node);
|
|
628
|
+
if (supervisorVerdict === "unknown")
|
|
629
|
+
return;
|
|
630
|
+
if (supervisorVerdict !== input.artifactVerdict) {
|
|
631
|
+
throw new Error(`repair artifact gate failed: verdict mismatch between supervisor ${supervisorVerdict} and REPAIR_ARTIFACT_JSON ${input.artifactVerdict}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function countSuccessfulShellCommands(stdout) {
|
|
635
|
+
if (!stdout)
|
|
636
|
+
return undefined;
|
|
637
|
+
const matches = stdout.match(/\|\s*\d+\s*\|\s*true\s*\|/g);
|
|
638
|
+
if (matches)
|
|
639
|
+
return matches.length;
|
|
640
|
+
if (/\|\s*\d+\s*\|\s*false\s*\|/.test(stdout))
|
|
641
|
+
return 0;
|
|
642
|
+
const explicit = stdout.match(/(?:passed|success(?:es)?|ok)\s*[=:]\s*(\d+)/i);
|
|
643
|
+
return explicit ? Number.parseInt(explicit[1], 10) : undefined;
|
|
644
|
+
}
|
|
645
|
+
function detectConvergenceRegression(history, current) {
|
|
646
|
+
const previous = [...history]
|
|
647
|
+
.reverse()
|
|
648
|
+
.find((pass) => pass.shellSuccessCount !== undefined);
|
|
649
|
+
if (previous?.shellSuccessCount !== undefined &&
|
|
650
|
+
current.shellSuccessCount !== undefined &&
|
|
651
|
+
current.shellSuccessCount < previous.shellSuccessCount) {
|
|
652
|
+
return true;
|
|
653
|
+
}
|
|
654
|
+
return isWorseFailureCategory(previous?.hardVerifyFailureCategory, current.hardVerifyFailureCategory);
|
|
655
|
+
}
|
|
656
|
+
function isWorseFailureCategory(previous, current) {
|
|
657
|
+
const severity = new Map([
|
|
658
|
+
["success", 0],
|
|
659
|
+
["nonzero-exit", 1],
|
|
660
|
+
["unknown", 2],
|
|
661
|
+
["timeout", 3],
|
|
662
|
+
["spawn-error", 3],
|
|
663
|
+
["write-guard", 4],
|
|
664
|
+
]);
|
|
665
|
+
if (!previous || !current)
|
|
666
|
+
return false;
|
|
667
|
+
return (severity.get(current) ?? 2) > (severity.get(previous) ?? 2);
|
|
668
|
+
}
|
|
669
|
+
async function appendConvergenceKnowledgePattern(input) {
|
|
670
|
+
const convergence = input.state.convergence;
|
|
671
|
+
if (!convergence || convergence.passHistory.length === 0)
|
|
672
|
+
return;
|
|
673
|
+
const supervisor = input.state.nodes["process-supervisor-pi"];
|
|
674
|
+
const hardVerify = input.state.nodes["hard-verify-shell"];
|
|
675
|
+
const pattern = {
|
|
676
|
+
schemaVersion: 1,
|
|
677
|
+
type: "dag-convergence-repair",
|
|
678
|
+
runId: input.state.runId,
|
|
679
|
+
recordedAt: new Date().toISOString(),
|
|
680
|
+
passCount: convergence.currentPass,
|
|
681
|
+
terminalReason: convergence.terminalReason ?? "hard-verify-pass",
|
|
682
|
+
processVerdict: parseProcessVerdict(supervisor),
|
|
683
|
+
repairArtifact: supervisor?.repairArtifact,
|
|
684
|
+
supervisorStructuredBlock: extractSupervisorStructuredBlock(supervisor),
|
|
685
|
+
hardVerify: {
|
|
686
|
+
status: hardVerify?.status,
|
|
687
|
+
failureCategory: hardVerify?.failureCategory,
|
|
688
|
+
nodeRecordPath: hardVerify?.nodeRecordPath,
|
|
689
|
+
},
|
|
690
|
+
passHistory: convergence.passHistory.map((pass) => ({
|
|
691
|
+
pass: pass.pass,
|
|
692
|
+
reason: pass.reason,
|
|
693
|
+
hardVerifyFailureCategory: pass.hardVerifyFailureCategory,
|
|
694
|
+
shellSuccessCount: pass.shellSuccessCount,
|
|
695
|
+
})),
|
|
696
|
+
};
|
|
697
|
+
const knowledgeDir = path.join(input.cwd, ".harness", "knowledge");
|
|
698
|
+
await mkdir(knowledgeDir, { recursive: true });
|
|
699
|
+
await appendFile(path.join(knowledgeDir, "patterns.jsonl"), `${JSON.stringify(pattern)}\n`, "utf-8");
|
|
700
|
+
}
|
|
701
|
+
function extractSupervisorStructuredBlock(node) {
|
|
702
|
+
// Legacy compatibility only. New supervisor prompts must emit REPAIR_ARTIFACT_JSON.
|
|
703
|
+
const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
|
|
704
|
+
const block = {};
|
|
705
|
+
for (const key of ["FAILURE_CLASS", "FIX_SCOPE", "INVARIANT"]) {
|
|
706
|
+
const match = text.match(new RegExp(`^${key}:\\s*(.+)$`, "im"));
|
|
707
|
+
if (match?.[1])
|
|
708
|
+
block[key] = match[1].trim();
|
|
709
|
+
}
|
|
710
|
+
return block;
|
|
711
|
+
}
|
|
712
|
+
async function resetConvergenceNodesForNextPass(input) {
|
|
713
|
+
const resetIds = new Set(convergenceChainNodeIds(input.tasksById));
|
|
714
|
+
for (const id of collectTransitiveDescendantTaskIds(input.spec, "hard-verify-shell")) {
|
|
715
|
+
const node = input.state.nodes[id];
|
|
716
|
+
if (node?.status === "SKIPPED")
|
|
717
|
+
resetIds.add(id);
|
|
718
|
+
}
|
|
719
|
+
for (const id of resetIds) {
|
|
720
|
+
const task = input.tasksById.get(id);
|
|
721
|
+
if (!task)
|
|
722
|
+
continue;
|
|
723
|
+
input.state.nodes[id] = freshNodeRecord(task);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function collectTransitiveDescendantTaskIds(spec, rootNodeId) {
|
|
727
|
+
const childrenByParent = new Map();
|
|
728
|
+
for (const task of spec.tasks) {
|
|
729
|
+
for (const parent of task.depends_on) {
|
|
730
|
+
const children = childrenByParent.get(parent) ?? [];
|
|
731
|
+
children.push(task.id);
|
|
732
|
+
childrenByParent.set(parent, children);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const descendants = new Set();
|
|
736
|
+
const queue = [...(childrenByParent.get(rootNodeId) ?? [])];
|
|
737
|
+
while (queue.length > 0) {
|
|
738
|
+
const id = queue.shift();
|
|
739
|
+
if (descendants.has(id))
|
|
740
|
+
continue;
|
|
741
|
+
descendants.add(id);
|
|
742
|
+
queue.push(...(childrenByParent.get(id) ?? []));
|
|
743
|
+
}
|
|
744
|
+
return descendants;
|
|
745
|
+
}
|
|
746
|
+
function freshNodeRecord(task) {
|
|
747
|
+
return {
|
|
748
|
+
id: task.id,
|
|
749
|
+
status: "PENDING",
|
|
750
|
+
executor: task.executor,
|
|
751
|
+
complexity: task.complexity,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
function relocateConvergenceArtifactPaths(state, oldRunDir, newRunDir) {
|
|
755
|
+
const convergence = state.convergence;
|
|
756
|
+
if (!convergence || oldRunDir === newRunDir)
|
|
757
|
+
return;
|
|
758
|
+
const relocate = (value) => value?.startsWith(oldRunDir)
|
|
759
|
+
? path.join(newRunDir, path.relative(oldRunDir, value))
|
|
760
|
+
: value;
|
|
761
|
+
for (const pass of convergence.passHistory) {
|
|
762
|
+
for (const ref of pass.artifactRefs) {
|
|
763
|
+
ref.nodeRecordPath = relocate(ref.nodeRecordPath);
|
|
764
|
+
ref.stdoutArtifactPath = relocate(ref.stdoutArtifactPath);
|
|
765
|
+
ref.assistantArtifactPath = relocate(ref.assistantArtifactPath);
|
|
766
|
+
ref.preservedNodeRecordPath = relocate(ref.preservedNodeRecordPath);
|
|
767
|
+
ref.preservedNodeDir = relocate(ref.preservedNodeDir);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function findRepairTaskForGate(input) {
|
|
772
|
+
return Array.from(input.tasksById.values()).find((candidate) => candidate.depends_on.includes(input.gateTask.id) &&
|
|
773
|
+
candidate.role === "implementer" &&
|
|
774
|
+
candidate.writePolicy === "exclusive" &&
|
|
775
|
+
(candidate.executor === "cursor" ||
|
|
776
|
+
(candidate.executor === "pi" && candidate.toolProfile === "write")));
|
|
777
|
+
}
|
|
778
|
+
function parseSupervisorRepairArtifact(node) {
|
|
779
|
+
const text = `${node?.assistantText ?? ""}\n${node?.stdout ?? ""}`;
|
|
780
|
+
return parseRepairArtifactFromText(text);
|
|
781
|
+
}
|
|
782
|
+
function validateRepairArtifactGateBeforeShell(input) {
|
|
783
|
+
const gate = input.task.shell?.repairArtifactGate;
|
|
784
|
+
if (!gate)
|
|
785
|
+
return;
|
|
786
|
+
const upstream = input.state.nodes[gate.fromNodeId];
|
|
787
|
+
const parsed = parseSupervisorRepairArtifact(upstream);
|
|
788
|
+
if (!parsed.ok) {
|
|
789
|
+
throw new Error(`repair artifact gate failed: ${parsed.reason}`);
|
|
790
|
+
}
|
|
791
|
+
assertRepairArtifactVerdictMatchesSupervisor({
|
|
792
|
+
node: upstream,
|
|
793
|
+
artifactVerdict: parsed.artifact.verdict,
|
|
794
|
+
});
|
|
795
|
+
upstream.repairArtifact = parsed.artifact;
|
|
796
|
+
const scoped = validateRepairArtifactScope({
|
|
797
|
+
artifact: parsed.artifact,
|
|
798
|
+
repairTask: findRepairTaskForGate({
|
|
799
|
+
tasksById: input.tasksById,
|
|
800
|
+
gateTask: input.task,
|
|
801
|
+
}),
|
|
802
|
+
});
|
|
803
|
+
if (!scoped.ok) {
|
|
804
|
+
throw new Error(`repair artifact gate failed: ${scoped.reason}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
function recordRepairArtifactForSupervisorNode(input) {
|
|
808
|
+
const dependentGate = Object.values(input.state.nodes).find((record) => record.id === "process-gate-shell");
|
|
809
|
+
if (input.task.id !== "process-supervisor-pi" || !dependentGate)
|
|
810
|
+
return;
|
|
811
|
+
const parsed = parseSupervisorRepairArtifact(input.node);
|
|
812
|
+
if (parsed.ok) {
|
|
813
|
+
input.node.repairArtifact = parsed.artifact;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
async function executeDagNode(input) {
|
|
817
|
+
const { nodeId, tasksById, state, spec, cwd, runDir, executeNode } = input;
|
|
818
|
+
const task = tasksById.get(nodeId);
|
|
819
|
+
const node = state.nodes[nodeId];
|
|
820
|
+
node.status = "RUNNING";
|
|
821
|
+
node.startedAt = new Date().toISOString();
|
|
822
|
+
if (task.shell?.verifyEvidence) {
|
|
823
|
+
node.verifyEvidence = task.shell.verifyEvidence;
|
|
824
|
+
}
|
|
825
|
+
await input.persistState();
|
|
826
|
+
await notifyNodeObserver(input.observer, "onNodeStart", nodeId, state);
|
|
827
|
+
if (task.dynamicExpansion ||
|
|
828
|
+
task.dynamicReduction ||
|
|
829
|
+
task.dynamicCondition ||
|
|
830
|
+
task.dynamicLoopUntil) {
|
|
831
|
+
const started = Date.now();
|
|
832
|
+
try {
|
|
833
|
+
const result = task.dynamicExpansion
|
|
834
|
+
? await executeDynamicMapExpansion({
|
|
835
|
+
task,
|
|
836
|
+
expansion: task.dynamicExpansion,
|
|
837
|
+
tasksById,
|
|
838
|
+
state,
|
|
839
|
+
spec,
|
|
840
|
+
cwd,
|
|
841
|
+
runDir,
|
|
842
|
+
executeNode,
|
|
843
|
+
observer: input.observer,
|
|
844
|
+
persistState: input.persistState,
|
|
845
|
+
})
|
|
846
|
+
: task.dynamicCondition
|
|
847
|
+
? await executeDynamicCondition({
|
|
848
|
+
task,
|
|
849
|
+
condition: task.dynamicCondition,
|
|
850
|
+
tasksById,
|
|
851
|
+
state,
|
|
852
|
+
})
|
|
853
|
+
: task.dynamicLoopUntil
|
|
854
|
+
? await executeDynamicLoopUntil({
|
|
855
|
+
task,
|
|
856
|
+
loop: task.dynamicLoopUntil,
|
|
857
|
+
tasksById,
|
|
858
|
+
state,
|
|
859
|
+
spec,
|
|
860
|
+
cwd,
|
|
861
|
+
runDir,
|
|
862
|
+
executeNode,
|
|
863
|
+
observer: input.observer,
|
|
864
|
+
persistState: input.persistState,
|
|
865
|
+
})
|
|
866
|
+
: await executeDynamicReduction({
|
|
867
|
+
task,
|
|
868
|
+
reduction: task.dynamicReduction,
|
|
869
|
+
state,
|
|
870
|
+
runDir,
|
|
871
|
+
});
|
|
872
|
+
node.durationMs = result.durationMs ?? Date.now() - started;
|
|
873
|
+
node.stdout = result.stdout;
|
|
874
|
+
node.stderr = result.stderr;
|
|
875
|
+
node.failureCategory = result.failureCategory;
|
|
876
|
+
node.finishedAt = new Date().toISOString();
|
|
877
|
+
node.status = result.ok ? "FINISHED" : "ERROR";
|
|
878
|
+
}
|
|
879
|
+
catch (error) {
|
|
880
|
+
node.status = "ERROR";
|
|
881
|
+
node.stderr = error instanceof Error ? error.message : String(error);
|
|
882
|
+
node.finishedAt = new Date().toISOString();
|
|
883
|
+
node.durationMs = Date.now() - started;
|
|
884
|
+
}
|
|
885
|
+
if (node.status === "FINISHED") {
|
|
886
|
+
const artifactMeta = await persistLongNodeOutputArtifacts({
|
|
887
|
+
runDir,
|
|
888
|
+
nodeId,
|
|
889
|
+
stdout: node.stdout,
|
|
890
|
+
assistantText: node.assistantText,
|
|
891
|
+
});
|
|
892
|
+
Object.assign(node, artifactMeta);
|
|
893
|
+
}
|
|
894
|
+
state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
|
|
895
|
+
await appendNodeLog(runDir, nodeId, state.nodes[nodeId]);
|
|
896
|
+
await input.persistState();
|
|
897
|
+
await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const { prompt, resolvedSkills } = await buildNodePromptWithResolvedSkillInstructions(spec, task, state.nodes, cwd);
|
|
901
|
+
node.resolvedSkills = resolvedSkills;
|
|
902
|
+
await writeNodeSkillArtifacts(runDir, nodeId, resolvedSkills);
|
|
903
|
+
const model = resolveModelForTask(task, spec.executorModels);
|
|
904
|
+
const started = Date.now();
|
|
905
|
+
try {
|
|
906
|
+
validateRepairArtifactGateBeforeShell({
|
|
907
|
+
task,
|
|
908
|
+
tasksById,
|
|
909
|
+
state,
|
|
910
|
+
});
|
|
911
|
+
const result = await executeNode({ task, cwd, model, prompt });
|
|
912
|
+
node.durationMs = result.durationMs ?? Date.now() - started;
|
|
913
|
+
node.stdout = result.stdout;
|
|
914
|
+
node.stderr = result.stderr;
|
|
915
|
+
node.failureCategory = result.failureCategory;
|
|
916
|
+
if (result.assistantText !== undefined) {
|
|
917
|
+
node.assistantText = result.assistantText;
|
|
918
|
+
}
|
|
919
|
+
if (result.backend !== undefined)
|
|
920
|
+
node.backend = result.backend;
|
|
921
|
+
if (result.sdkAttempted !== undefined) {
|
|
922
|
+
node.sdkAttempted = result.sdkAttempted;
|
|
923
|
+
}
|
|
924
|
+
if (result.tokensUsed !== undefined)
|
|
925
|
+
node.tokensUsed = result.tokensUsed;
|
|
926
|
+
if (result.parsedEvents !== undefined) {
|
|
927
|
+
node.parsedEvents = result.parsedEvents;
|
|
928
|
+
}
|
|
929
|
+
recordRepairArtifactForSupervisorNode({
|
|
930
|
+
task,
|
|
931
|
+
node,
|
|
932
|
+
state,
|
|
933
|
+
});
|
|
934
|
+
const outputChunk = result.assistantText ?? result.stdout;
|
|
935
|
+
if (outputChunk.trim()) {
|
|
936
|
+
await notifyNodeObserver(input.observer, "onNodeOutput", nodeId, state, outputChunk);
|
|
937
|
+
}
|
|
938
|
+
node.finishedAt = new Date().toISOString();
|
|
939
|
+
node.status = result.ok ? "FINISHED" : "ERROR";
|
|
940
|
+
if (result.ok) {
|
|
941
|
+
const decisionRecord = await recordDecisionEnvelopeForNode({
|
|
942
|
+
task,
|
|
943
|
+
runDir,
|
|
944
|
+
nodeId,
|
|
945
|
+
assistantText: node.assistantText ?? result.assistantText,
|
|
946
|
+
});
|
|
947
|
+
if (decisionRecord) {
|
|
948
|
+
node.decisionEnvelope = decisionRecord.nodeRecord;
|
|
949
|
+
if (!decisionRecord.nodeRecord.parseOk) {
|
|
950
|
+
node.status = "ERROR";
|
|
951
|
+
node.failureCategory = "decision-envelope-invalid";
|
|
952
|
+
node.stderr = [
|
|
953
|
+
node.stderr,
|
|
954
|
+
...(decisionRecord.nodeRecord.errors ?? []),
|
|
955
|
+
]
|
|
956
|
+
.filter(Boolean)
|
|
957
|
+
.join("\n");
|
|
958
|
+
}
|
|
959
|
+
else if (shouldPauseOnHumanEscalation(task, decisionRecord.nodeRecord) &&
|
|
960
|
+
decisionRecord.envelope) {
|
|
961
|
+
const pausedAt = new Date().toISOString();
|
|
962
|
+
const nodeArtifactsDir = path.join(runDir, nodeId);
|
|
963
|
+
const { jsonPath } = await writeHumanEscalationArtifacts({
|
|
964
|
+
nodeArtifactsDir,
|
|
965
|
+
runId: state.runId,
|
|
966
|
+
nodeId,
|
|
967
|
+
envelope: decisionRecord.envelope,
|
|
968
|
+
pausedAt,
|
|
969
|
+
});
|
|
970
|
+
node.pauseReason = "decision-gate-requires-human";
|
|
971
|
+
node.escalationArtifactPath = jsonPath;
|
|
972
|
+
input.onPause(nodeId, pausedAt, node.pauseReason);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
node.status = "ERROR";
|
|
979
|
+
node.stderr = error instanceof Error ? error.message : String(error);
|
|
980
|
+
node.finishedAt = new Date().toISOString();
|
|
981
|
+
node.durationMs = Date.now() - started;
|
|
982
|
+
}
|
|
983
|
+
if (node.status === "FINISHED") {
|
|
984
|
+
const artifactMeta = await persistLongNodeOutputArtifacts({
|
|
985
|
+
runDir,
|
|
986
|
+
nodeId,
|
|
987
|
+
stdout: node.stdout,
|
|
988
|
+
assistantText: node.assistantText,
|
|
989
|
+
});
|
|
990
|
+
Object.assign(node, artifactMeta);
|
|
991
|
+
}
|
|
992
|
+
state.nodes[nodeId].nodeRecordPath = path.join(runDir, `${nodeId}.json`);
|
|
993
|
+
await appendNodeLog(runDir, nodeId, state.nodes[nodeId]);
|
|
994
|
+
await input.persistState();
|
|
995
|
+
await notifyNodeObserver(input.observer, "onNodeFinish", nodeId, state);
|
|
996
|
+
}
|
|
997
|
+
function sha256Json(value) {
|
|
998
|
+
return `sha256:${createHash("sha256")
|
|
999
|
+
.update(JSON.stringify(value))
|
|
1000
|
+
.digest("hex")}`;
|
|
1001
|
+
}
|
|
1002
|
+
function getNodeOutputAsJson(state, nodeId) {
|
|
1003
|
+
const record = state.nodes[nodeId];
|
|
1004
|
+
if (!record || record.status !== "FINISHED") {
|
|
1005
|
+
throw new Error(`itemsFrom upstream node "${nodeId}" is not finished`);
|
|
1006
|
+
}
|
|
1007
|
+
const raw = record.stdout?.trim() || record.assistantText?.trim();
|
|
1008
|
+
if (!raw) {
|
|
1009
|
+
throw new Error(`itemsFrom upstream node "${nodeId}" has no JSON output`);
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
return JSON.parse(raw);
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
throw new Error(`itemsFrom upstream node "${nodeId}" output is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function resolveOutputSelector(selector, state) {
|
|
1019
|
+
const match = selector.match(/^\$\.nodes\[['"]([^'"]+)['"]\]\.output(?:\.(.+))?$/);
|
|
1020
|
+
if (!match?.[1]) {
|
|
1021
|
+
throw new Error(`unsupported itemsFrom selector: ${selector}`);
|
|
1022
|
+
}
|
|
1023
|
+
const nodeId = match[1];
|
|
1024
|
+
const pathSuffix = match[2];
|
|
1025
|
+
let current = getNodeOutputAsJson(state, nodeId);
|
|
1026
|
+
if (pathSuffix) {
|
|
1027
|
+
for (const segment of pathSuffix.split(".")) {
|
|
1028
|
+
if (current &&
|
|
1029
|
+
typeof current === "object" &&
|
|
1030
|
+
segment in current) {
|
|
1031
|
+
current = current[segment];
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
throw new Error(`output selector path not found: ${selector} (missing "${segment}")`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return current;
|
|
1038
|
+
}
|
|
1039
|
+
function resolveItemsFromSelector(selector, state) {
|
|
1040
|
+
let current;
|
|
1041
|
+
try {
|
|
1042
|
+
current = resolveOutputSelector(selector, state);
|
|
1043
|
+
}
|
|
1044
|
+
catch (error) {
|
|
1045
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1046
|
+
if (message.startsWith("output selector path not found:")) {
|
|
1047
|
+
throw new Error(`itemsFrom path not found: ${selector}`);
|
|
1048
|
+
}
|
|
1049
|
+
throw error;
|
|
1050
|
+
}
|
|
1051
|
+
if (!Array.isArray(current)) {
|
|
1052
|
+
throw new Error(`itemsFrom selector did not resolve to an array: ${selector}`);
|
|
1053
|
+
}
|
|
1054
|
+
return current;
|
|
1055
|
+
}
|
|
1056
|
+
function parseConditionLiteral(raw) {
|
|
1057
|
+
const trimmed = raw.trim();
|
|
1058
|
+
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
1059
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))) {
|
|
1060
|
+
return trimmed.slice(1, -1);
|
|
1061
|
+
}
|
|
1062
|
+
if (trimmed === "true")
|
|
1063
|
+
return true;
|
|
1064
|
+
if (trimmed === "false")
|
|
1065
|
+
return false;
|
|
1066
|
+
if (trimmed === "null")
|
|
1067
|
+
return null;
|
|
1068
|
+
const numeric = Number(trimmed);
|
|
1069
|
+
return Number.isFinite(numeric) ? numeric : trimmed;
|
|
1070
|
+
}
|
|
1071
|
+
function evaluateConditionExpression(expression, state) {
|
|
1072
|
+
const equality = expression.match(/^\s*(\$\..+?)\s*==\s*(.+?)\s*$/);
|
|
1073
|
+
if (!equality?.[1]) {
|
|
1074
|
+
throw new Error(`unsupported condition expression: ${expression}`);
|
|
1075
|
+
}
|
|
1076
|
+
const actual = resolveOutputSelector(equality[1], state);
|
|
1077
|
+
const expected = parseConditionLiteral(equality[2] ?? "");
|
|
1078
|
+
return actual === expected;
|
|
1079
|
+
}
|
|
1080
|
+
function evaluateLoopStopExpression(input) {
|
|
1081
|
+
const equality = input.expression.match(/^\s*(\$\..+?)\s*==\s*(.+?)\s*$/);
|
|
1082
|
+
if (!equality?.[1]) {
|
|
1083
|
+
throw new Error(`unsupported loop_until stop expression: ${input.expression}`);
|
|
1084
|
+
}
|
|
1085
|
+
const selector = equality[1];
|
|
1086
|
+
let actual;
|
|
1087
|
+
if (selector.startsWith("$.last.output.")) {
|
|
1088
|
+
actual = lookupPath(input.lastOutput, selector.slice("$.last.output.".length));
|
|
1089
|
+
}
|
|
1090
|
+
else if (selector === "$.iteration") {
|
|
1091
|
+
actual = input.iteration;
|
|
1092
|
+
}
|
|
1093
|
+
else {
|
|
1094
|
+
throw new Error(`unsupported loop_until stop selector: ${selector}`);
|
|
1095
|
+
}
|
|
1096
|
+
const expected = parseConditionLiteral(equality[2] ?? "");
|
|
1097
|
+
return actual === expected;
|
|
1098
|
+
}
|
|
1099
|
+
function executeDynamicCondition(input) {
|
|
1100
|
+
const started = Date.now();
|
|
1101
|
+
const matchedIndex = input.condition.cases.findIndex((conditionCase) => evaluateConditionExpression(conditionCase.when, input.state));
|
|
1102
|
+
const selected = matchedIndex >= 0
|
|
1103
|
+
? input.condition.cases[matchedIndex]?.then
|
|
1104
|
+
: input.condition.default;
|
|
1105
|
+
if (!selected) {
|
|
1106
|
+
throw new Error(`condition ${input.task.id} did not match and has no default`);
|
|
1107
|
+
}
|
|
1108
|
+
if (!input.tasksById.has(selected)) {
|
|
1109
|
+
throw new Error(`condition ${input.task.id} selected missing target ${selected}`);
|
|
1110
|
+
}
|
|
1111
|
+
const branchTargets = new Set([
|
|
1112
|
+
...input.condition.cases.map((conditionCase) => conditionCase.then),
|
|
1113
|
+
...(input.condition.default ? [input.condition.default] : []),
|
|
1114
|
+
]);
|
|
1115
|
+
for (const target of branchTargets) {
|
|
1116
|
+
if (target === selected)
|
|
1117
|
+
continue;
|
|
1118
|
+
const record = input.state.nodes[target];
|
|
1119
|
+
if (record?.status === "PENDING") {
|
|
1120
|
+
record.status = "SKIPPED";
|
|
1121
|
+
record.skippedReason = `condition ${input.task.id} selected ${selected}; branch not selected`;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
ok: true,
|
|
1126
|
+
stdout: JSON.stringify({
|
|
1127
|
+
workflowNodeId: input.condition.workflowNodeId,
|
|
1128
|
+
selected,
|
|
1129
|
+
matchedCaseIndex: matchedIndex >= 0 ? matchedIndex : undefined,
|
|
1130
|
+
defaulted: matchedIndex < 0,
|
|
1131
|
+
}),
|
|
1132
|
+
stderr: "",
|
|
1133
|
+
failureCategory: "success",
|
|
1134
|
+
durationMs: Date.now() - started,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
function renderLoopTemplate(template, iteration) {
|
|
1138
|
+
const iterationStatus = iteration >= 2 ? "passed" : "failed";
|
|
1139
|
+
return template
|
|
1140
|
+
.replace(/\{\{\s*iteration\s*\}\}/g, String(iteration))
|
|
1141
|
+
.replace(/\{\{\s*iterationStatus\s*\}\}/g, iterationStatus);
|
|
1142
|
+
}
|
|
1143
|
+
function buildLoopBodyChildTask(input) {
|
|
1144
|
+
const { parent, bodyTask, iteration, nodeId, bodyIdMap } = input;
|
|
1145
|
+
const mappedDepends = bodyTask.dependsOn.map((depId) => bodyIdMap.get(depId) ?? depId);
|
|
1146
|
+
return {
|
|
1147
|
+
id: nodeId,
|
|
1148
|
+
depends_on: mappedDepends.length > 0 ? mappedDepends : parent.depends_on,
|
|
1149
|
+
complexity: bodyTask.complexity,
|
|
1150
|
+
subtask_prompt: renderLoopTemplate(bodyTask.subtaskPromptTemplate, iteration),
|
|
1151
|
+
executor: bodyTask.executor,
|
|
1152
|
+
role: bodyTask.role,
|
|
1153
|
+
writePolicy: bodyTask.writePolicy,
|
|
1154
|
+
allowedPaths: bodyTask.allowedPaths,
|
|
1155
|
+
forbiddenPaths: bodyTask.forbiddenPaths,
|
|
1156
|
+
writeSet: bodyTask.writeSet,
|
|
1157
|
+
outputContract: bodyTask.outputContract,
|
|
1158
|
+
static: bodyTask.executor === "static"
|
|
1159
|
+
? {
|
|
1160
|
+
resultMarkdown: bodyTask.staticResultTemplate
|
|
1161
|
+
? renderLoopTemplate(bodyTask.staticResultTemplate, iteration)
|
|
1162
|
+
: "dynamic loop child completed",
|
|
1163
|
+
}
|
|
1164
|
+
: undefined,
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
async function executeDynamicLoopUntil(input) {
|
|
1168
|
+
const started = Date.now();
|
|
1169
|
+
const executedChildren = [];
|
|
1170
|
+
let stopped = false;
|
|
1171
|
+
let stopReason = "";
|
|
1172
|
+
let iterations = 0;
|
|
1173
|
+
for (let iteration = 1; iteration <= input.loop.maxIterations; iteration += 1) {
|
|
1174
|
+
iterations = iteration;
|
|
1175
|
+
const bodyIdMap = new Map(input.loop.bodyTasks.map((bodyTask) => [
|
|
1176
|
+
bodyTask.id,
|
|
1177
|
+
`${input.task.id}-r${iteration}-${bodyTask.id}`,
|
|
1178
|
+
]));
|
|
1179
|
+
const children = input.loop.bodyTasks.map((bodyTask) => buildLoopBodyChildTask({
|
|
1180
|
+
parent: input.task,
|
|
1181
|
+
loop: input.loop,
|
|
1182
|
+
bodyTask,
|
|
1183
|
+
iteration,
|
|
1184
|
+
nodeId: bodyIdMap.get(bodyTask.id),
|
|
1185
|
+
bodyIdMap,
|
|
1186
|
+
}));
|
|
1187
|
+
for (const child of children) {
|
|
1188
|
+
if (!input.tasksById.has(child.id)) {
|
|
1189
|
+
input.tasksById.set(child.id, child);
|
|
1190
|
+
input.spec.tasks.push(child);
|
|
1191
|
+
}
|
|
1192
|
+
if (!input.state.nodes[child.id]) {
|
|
1193
|
+
input.state.nodes[child.id] = freshNodeRecord(child);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
input.state.ranks.push(children.map((child) => child.id));
|
|
1197
|
+
await writeDagRunSpec(input.runDir, input.spec);
|
|
1198
|
+
await input.persistState();
|
|
1199
|
+
for (const child of children) {
|
|
1200
|
+
await executeDagNode({
|
|
1201
|
+
nodeId: child.id,
|
|
1202
|
+
tasksById: input.tasksById,
|
|
1203
|
+
state: input.state,
|
|
1204
|
+
spec: input.spec,
|
|
1205
|
+
cwd: input.cwd,
|
|
1206
|
+
runDir: input.runDir,
|
|
1207
|
+
executeNode: input.executeNode,
|
|
1208
|
+
observer: input.observer,
|
|
1209
|
+
persistState: input.persistState,
|
|
1210
|
+
onPause: () => {
|
|
1211
|
+
throw new Error(`dynamic child node ${child.id} requested a human pause; loop_until children do not support pause in v0`);
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
executedChildren.push(child.id);
|
|
1215
|
+
}
|
|
1216
|
+
const failedChild = children.find((child) => input.state.nodes[child.id]?.status !== "FINISHED");
|
|
1217
|
+
if (failedChild) {
|
|
1218
|
+
return {
|
|
1219
|
+
ok: false,
|
|
1220
|
+
stdout: JSON.stringify({
|
|
1221
|
+
workflowNodeId: input.loop.workflowNodeId,
|
|
1222
|
+
iterations,
|
|
1223
|
+
stopped: false,
|
|
1224
|
+
children: executedChildren,
|
|
1225
|
+
}),
|
|
1226
|
+
stderr: `loop_until child failed: ${failedChild.id}`,
|
|
1227
|
+
failureCategory: "dynamic-loop-child-failed",
|
|
1228
|
+
durationMs: Date.now() - started,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
const lastChild = children[children.length - 1];
|
|
1232
|
+
const lastOutput = parseJsonFromText(input.state.nodes[lastChild.id]?.stdout);
|
|
1233
|
+
const matched = input.loop.stopWhenAny.find((expression) => evaluateLoopStopExpression({
|
|
1234
|
+
expression,
|
|
1235
|
+
lastOutput,
|
|
1236
|
+
iteration,
|
|
1237
|
+
}));
|
|
1238
|
+
if (matched) {
|
|
1239
|
+
stopped = true;
|
|
1240
|
+
stopReason = matched;
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
const manifest = {
|
|
1245
|
+
workflowNodeId: input.loop.workflowNodeId,
|
|
1246
|
+
expandedAt: new Date().toISOString(),
|
|
1247
|
+
maxIterations: input.loop.maxIterations,
|
|
1248
|
+
iterations,
|
|
1249
|
+
stopped,
|
|
1250
|
+
stopReason: stopped ? stopReason : undefined,
|
|
1251
|
+
children: executedChildren.map((nodeId) => ({ nodeId })),
|
|
1252
|
+
};
|
|
1253
|
+
const expansionDir = path.join(input.runDir, "expansions");
|
|
1254
|
+
await mkdir(expansionDir, { recursive: true });
|
|
1255
|
+
await writeFile(path.join(expansionDir, `${input.task.id}.expansion.json`), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
1256
|
+
return {
|
|
1257
|
+
ok: true,
|
|
1258
|
+
stdout: JSON.stringify({
|
|
1259
|
+
workflowNodeId: input.loop.workflowNodeId,
|
|
1260
|
+
iterations,
|
|
1261
|
+
stopped,
|
|
1262
|
+
stopReason: stopped ? stopReason : undefined,
|
|
1263
|
+
maxIterationsReached: !stopped,
|
|
1264
|
+
children: executedChildren,
|
|
1265
|
+
}),
|
|
1266
|
+
stderr: "",
|
|
1267
|
+
failureCategory: "success",
|
|
1268
|
+
durationMs: Date.now() - started,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
function lookupPath(value, pathExpression) {
|
|
1272
|
+
let current = value;
|
|
1273
|
+
for (const segment of pathExpression.split(".")) {
|
|
1274
|
+
if (current &&
|
|
1275
|
+
typeof current === "object" &&
|
|
1276
|
+
segment in current) {
|
|
1277
|
+
current = current[segment];
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
return undefined;
|
|
1281
|
+
}
|
|
1282
|
+
return current;
|
|
1283
|
+
}
|
|
1284
|
+
function templateValueToString(value) {
|
|
1285
|
+
if (typeof value === "string" ||
|
|
1286
|
+
typeof value === "number" ||
|
|
1287
|
+
typeof value === "boolean") {
|
|
1288
|
+
return String(value);
|
|
1289
|
+
}
|
|
1290
|
+
if (value === null || value === undefined)
|
|
1291
|
+
return "";
|
|
1292
|
+
return JSON.stringify(value);
|
|
1293
|
+
}
|
|
1294
|
+
function parseJsonFromText(value) {
|
|
1295
|
+
if (!value?.trim())
|
|
1296
|
+
return undefined;
|
|
1297
|
+
try {
|
|
1298
|
+
return JSON.parse(value);
|
|
1299
|
+
}
|
|
1300
|
+
catch {
|
|
1301
|
+
return undefined;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function renderDynamicTemplate(template, item, index, itemName) {
|
|
1305
|
+
const primitive = typeof item === "string" ||
|
|
1306
|
+
typeof item === "number" ||
|
|
1307
|
+
typeof item === "boolean"
|
|
1308
|
+
? String(item)
|
|
1309
|
+
: JSON.stringify(item);
|
|
1310
|
+
return template
|
|
1311
|
+
.replace(/\{\{\s*item\s*\}\}/g, primitive)
|
|
1312
|
+
.replace(new RegExp(`\\{\\{\\s*${itemName}\\s*\\}\\}`, "g"), primitive)
|
|
1313
|
+
.replace(new RegExp(`\\{\\{\\s*${itemName}\\.([A-Za-z0-9_.-]+)\\s*\\}\\}`, "g"), (_match, pathExpression) => templateValueToString(lookupPath(item, pathExpression)))
|
|
1314
|
+
.replace(/\{\{\s*item\.([A-Za-z0-9_.-]+)\s*\}\}/g, (_match, pathExpression) => templateValueToString(lookupPath(item, pathExpression)))
|
|
1315
|
+
.replace(/\{\{\s*itemJson\s*\}\}/g, JSON.stringify(item))
|
|
1316
|
+
.replace(/\{\{\s*index\s*\}\}/g, String(index));
|
|
1317
|
+
}
|
|
1318
|
+
function renderDynamicPatternList(patterns, item, index, itemName) {
|
|
1319
|
+
return patterns?.map((pattern) => renderDynamicTemplate(pattern, item, index, itemName));
|
|
1320
|
+
}
|
|
1321
|
+
function buildExpandedChildTask(input) {
|
|
1322
|
+
const { parent, expansion, item, index, nodeId } = input;
|
|
1323
|
+
const child = expansion.childTask;
|
|
1324
|
+
const subtaskPrompt = renderDynamicTemplate(child.subtaskPromptTemplate, item, index, expansion.itemName);
|
|
1325
|
+
const staticResult = child.staticResultTemplate
|
|
1326
|
+
? renderDynamicTemplate(child.staticResultTemplate, item, index, expansion.itemName)
|
|
1327
|
+
: undefined;
|
|
1328
|
+
return {
|
|
1329
|
+
id: nodeId,
|
|
1330
|
+
depends_on: parent.depends_on,
|
|
1331
|
+
complexity: child.complexity,
|
|
1332
|
+
subtask_prompt: subtaskPrompt,
|
|
1333
|
+
executor: child.executor,
|
|
1334
|
+
role: child.role,
|
|
1335
|
+
writePolicy: child.writePolicy,
|
|
1336
|
+
allowedPaths: renderDynamicPatternList(child.allowedPaths, item, index, expansion.itemName) ?? [],
|
|
1337
|
+
forbiddenPaths: renderDynamicPatternList(child.forbiddenPaths, item, index, expansion.itemName) ?? [],
|
|
1338
|
+
writeSet: renderDynamicPatternList(child.writeSet, item, index, expansion.itemName),
|
|
1339
|
+
outputContract: child.outputContract,
|
|
1340
|
+
static: child.executor === "static"
|
|
1341
|
+
? { resultMarkdown: staticResult ?? "dynamic child completed" }
|
|
1342
|
+
: undefined,
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
function addExpansionRank(input) {
|
|
1346
|
+
if (input.state.ranks.some((rank) => input.childNodeIds.every((nodeId) => rank.includes(nodeId)))) {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const parentRankIndex = input.state.ranks.findIndex((rank) => rank.includes(input.parentNodeId));
|
|
1350
|
+
const insertAt = parentRankIndex >= 0 ? parentRankIndex + 1 : input.state.ranks.length;
|
|
1351
|
+
input.state.ranks.splice(insertAt, 0, input.childNodeIds);
|
|
1352
|
+
}
|
|
1353
|
+
function normalizedWritePattern(pattern) {
|
|
1354
|
+
return pattern.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
1355
|
+
}
|
|
1356
|
+
function writePatternsOverlap(left, right) {
|
|
1357
|
+
const normalizedLeft = normalizedWritePattern(left);
|
|
1358
|
+
const normalizedRight = normalizedWritePattern(right);
|
|
1359
|
+
if (normalizedLeft === normalizedRight)
|
|
1360
|
+
return true;
|
|
1361
|
+
const leftPrefix = normalizedLeft.replace(/\*\*.*$/, "");
|
|
1362
|
+
const rightPrefix = normalizedRight.replace(/\*\*.*$/, "");
|
|
1363
|
+
return (leftPrefix.length > 0 &&
|
|
1364
|
+
rightPrefix.length > 0 &&
|
|
1365
|
+
(leftPrefix.startsWith(rightPrefix) || rightPrefix.startsWith(leftPrefix)));
|
|
1366
|
+
}
|
|
1367
|
+
function collectDynamicChildWriteSetConflicts(children) {
|
|
1368
|
+
const conflicts = [];
|
|
1369
|
+
const exclusiveChildren = children.filter((child) => child.writePolicy === "exclusive" && (child.writeSet?.length ?? 0) > 0);
|
|
1370
|
+
for (let i = 0; i < exclusiveChildren.length; i += 1) {
|
|
1371
|
+
for (let j = i + 1; j < exclusiveChildren.length; j += 1) {
|
|
1372
|
+
const left = exclusiveChildren[i];
|
|
1373
|
+
const right = exclusiveChildren[j];
|
|
1374
|
+
for (const leftEntry of left.writeSet ?? []) {
|
|
1375
|
+
for (const rightEntry of right.writeSet ?? []) {
|
|
1376
|
+
if (writePatternsOverlap(leftEntry, rightEntry)) {
|
|
1377
|
+
conflicts.push(`${left.id}:${leftEntry} overlaps ${right.id}:${rightEntry}`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return conflicts;
|
|
1384
|
+
}
|
|
1385
|
+
function resolveRunLocalPath(runDir, ref, label) {
|
|
1386
|
+
if (path.isAbsolute(ref)) {
|
|
1387
|
+
throw new Error(`${label} must be relative to the DAG run directory: ${ref}`);
|
|
1388
|
+
}
|
|
1389
|
+
const runRoot = path.resolve(runDir);
|
|
1390
|
+
const resolved = path.resolve(runRoot, ref);
|
|
1391
|
+
if (resolved !== runRoot && !resolved.startsWith(`${runRoot}${path.sep}`)) {
|
|
1392
|
+
throw new Error(`${label} escapes the DAG run directory: ${ref}`);
|
|
1393
|
+
}
|
|
1394
|
+
return resolved;
|
|
1395
|
+
}
|
|
1396
|
+
async function executeDynamicMapExpansion(input) {
|
|
1397
|
+
const started = Date.now();
|
|
1398
|
+
const items = resolveItemsFromSelector(input.expansion.itemsFrom, input.state);
|
|
1399
|
+
if (items.length > input.expansion.maxExpandedNodes) {
|
|
1400
|
+
throw new Error(`map_agent ${input.task.id} item count ${items.length} exceeds maxExpandedNodes ${input.expansion.maxExpandedNodes}`);
|
|
1401
|
+
}
|
|
1402
|
+
if (items.length > input.expansion.maxItems) {
|
|
1403
|
+
throw new Error(`map_agent ${input.task.id} item count ${items.length} exceeds maxItems ${input.expansion.maxItems}`);
|
|
1404
|
+
}
|
|
1405
|
+
const childNodeIds = items.map((_, index) => `${input.expansion.childIdPrefix}-${String(index + 1).padStart(4, "0")}`);
|
|
1406
|
+
const children = childNodeIds.map((nodeId, index) => buildExpandedChildTask({
|
|
1407
|
+
parent: input.task,
|
|
1408
|
+
expansion: input.expansion,
|
|
1409
|
+
item: items[index],
|
|
1410
|
+
index,
|
|
1411
|
+
nodeId,
|
|
1412
|
+
}));
|
|
1413
|
+
const writeSetConflicts = collectDynamicChildWriteSetConflicts(children);
|
|
1414
|
+
if (writeSetConflicts.length > 0) {
|
|
1415
|
+
throw new Error(`dynamic map_agent ${input.task.id} expanded overlapping writeSets: ${writeSetConflicts.join("; ")}`);
|
|
1416
|
+
}
|
|
1417
|
+
const workspaceRefs = items.map((item, index) => input.expansion.workspaceTemplate
|
|
1418
|
+
? renderDynamicTemplate(input.expansion.workspaceTemplate, item, index, input.expansion.itemName)
|
|
1419
|
+
: undefined);
|
|
1420
|
+
for (const workspaceRef of workspaceRefs) {
|
|
1421
|
+
if (!workspaceRef)
|
|
1422
|
+
continue;
|
|
1423
|
+
await mkdir(resolveRunLocalPath(input.runDir, workspaceRef, "workspaceRef"), {
|
|
1424
|
+
recursive: true,
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
for (const child of children) {
|
|
1428
|
+
if (!input.tasksById.has(child.id)) {
|
|
1429
|
+
input.tasksById.set(child.id, child);
|
|
1430
|
+
input.spec.tasks.push(child);
|
|
1431
|
+
}
|
|
1432
|
+
if (!input.state.nodes[child.id]) {
|
|
1433
|
+
input.state.nodes[child.id] = freshNodeRecord(child);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
addExpansionRank({
|
|
1437
|
+
state: input.state,
|
|
1438
|
+
parentNodeId: input.task.id,
|
|
1439
|
+
childNodeIds,
|
|
1440
|
+
});
|
|
1441
|
+
await writeDagRunSpec(input.runDir, input.spec);
|
|
1442
|
+
const manifest = {
|
|
1443
|
+
workflowNodeId: input.expansion.workflowNodeId,
|
|
1444
|
+
expandedAt: new Date().toISOString(),
|
|
1445
|
+
itemsFrom: input.expansion.itemsFrom,
|
|
1446
|
+
itemCount: items.length,
|
|
1447
|
+
children: childNodeIds.map((nodeId, index) => ({
|
|
1448
|
+
nodeId,
|
|
1449
|
+
itemRef: `$.items[${index}]`,
|
|
1450
|
+
itemValueHash: sha256Json(items[index]),
|
|
1451
|
+
workspaceRef: workspaceRefs[index],
|
|
1452
|
+
})),
|
|
1453
|
+
};
|
|
1454
|
+
const expansionDir = path.join(input.runDir, "expansions");
|
|
1455
|
+
await mkdir(expansionDir, { recursive: true });
|
|
1456
|
+
await writeFile(path.join(expansionDir, `${input.task.id}.expansion.json`), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
1457
|
+
await input.persistState();
|
|
1458
|
+
for (const child of children) {
|
|
1459
|
+
const childRecord = input.state.nodes[child.id];
|
|
1460
|
+
if (childRecord?.status === "FINISHED")
|
|
1461
|
+
continue;
|
|
1462
|
+
await executeDagNode({
|
|
1463
|
+
nodeId: child.id,
|
|
1464
|
+
tasksById: input.tasksById,
|
|
1465
|
+
state: input.state,
|
|
1466
|
+
spec: input.spec,
|
|
1467
|
+
cwd: input.cwd,
|
|
1468
|
+
runDir: input.runDir,
|
|
1469
|
+
executeNode: input.executeNode,
|
|
1470
|
+
observer: input.observer,
|
|
1471
|
+
persistState: input.persistState,
|
|
1472
|
+
onPause: () => {
|
|
1473
|
+
throw new Error(`dynamic child node ${child.id} requested a human pause; map_agent children do not support pause in v0`);
|
|
1474
|
+
},
|
|
1475
|
+
});
|
|
1476
|
+
}
|
|
1477
|
+
const failedChildren = childNodeIds.filter((nodeId) => input.state.nodes[nodeId]?.status !== "FINISHED");
|
|
1478
|
+
const aggregate = {
|
|
1479
|
+
workflowNodeId: input.expansion.workflowNodeId,
|
|
1480
|
+
itemCount: items.length,
|
|
1481
|
+
children: childNodeIds.map((nodeId, index) => ({
|
|
1482
|
+
nodeId,
|
|
1483
|
+
item: items[index],
|
|
1484
|
+
workspaceRef: workspaceRefs[index],
|
|
1485
|
+
status: input.state.nodes[nodeId]?.status,
|
|
1486
|
+
stdout: input.state.nodes[nodeId]?.stdout,
|
|
1487
|
+
output: parseJsonFromText(input.state.nodes[nodeId]?.stdout),
|
|
1488
|
+
assistantText: input.state.nodes[nodeId]?.assistantText,
|
|
1489
|
+
})),
|
|
1490
|
+
};
|
|
1491
|
+
return {
|
|
1492
|
+
ok: failedChildren.length === 0,
|
|
1493
|
+
stdout: JSON.stringify(aggregate),
|
|
1494
|
+
stderr: failedChildren.length > 0
|
|
1495
|
+
? `dynamic map children failed: ${failedChildren.join(", ")}`
|
|
1496
|
+
: "",
|
|
1497
|
+
failureCategory: failedChildren.length > 0 ? "dynamic-expansion-child-failed" : "success",
|
|
1498
|
+
durationMs: Date.now() - started,
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
function parseFindingsFromSelector(selector, state) {
|
|
1502
|
+
const raw = resolveOutputSelector(selector, state);
|
|
1503
|
+
if (!Array.isArray(raw)) {
|
|
1504
|
+
throw new Error(`findings selector did not resolve to an array: ${selector}`);
|
|
1505
|
+
}
|
|
1506
|
+
return raw.map((entry) => {
|
|
1507
|
+
const direct = findingSchema.safeParse(entry);
|
|
1508
|
+
if (direct.success)
|
|
1509
|
+
return direct.data;
|
|
1510
|
+
if (entry && typeof entry === "object") {
|
|
1511
|
+
const output = entry.output;
|
|
1512
|
+
const fromOutput = findingSchema.safeParse(output);
|
|
1513
|
+
if (fromOutput.success)
|
|
1514
|
+
return fromOutput.data;
|
|
1515
|
+
const stdout = entry.stdout;
|
|
1516
|
+
if (typeof stdout === "string") {
|
|
1517
|
+
const fromStdout = findingSchema.safeParse(parseJsonFromText(stdout));
|
|
1518
|
+
if (fromStdout.success)
|
|
1519
|
+
return fromStdout.data;
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return findingSchema.parse(entry);
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
function parseVerificationFromChildEntry(entry) {
|
|
1526
|
+
if (!entry || typeof entry !== "object")
|
|
1527
|
+
return undefined;
|
|
1528
|
+
const stdout = entry.stdout;
|
|
1529
|
+
if (typeof stdout !== "string" || stdout.trim().length === 0) {
|
|
1530
|
+
return undefined;
|
|
1531
|
+
}
|
|
1532
|
+
return verificationSchema.parse(JSON.parse(stdout));
|
|
1533
|
+
}
|
|
1534
|
+
function parseVerificationsFromSelector(selector, state) {
|
|
1535
|
+
const raw = resolveOutputSelector(selector, state);
|
|
1536
|
+
if (!Array.isArray(raw)) {
|
|
1537
|
+
throw new Error(`verifications selector did not resolve to an array: ${selector}`);
|
|
1538
|
+
}
|
|
1539
|
+
return raw
|
|
1540
|
+
.map(parseVerificationFromChildEntry)
|
|
1541
|
+
.filter((entry) => Boolean(entry));
|
|
1542
|
+
}
|
|
1543
|
+
async function executeDynamicReduction(input) {
|
|
1544
|
+
const started = Date.now();
|
|
1545
|
+
if (input.reduction.type !== "verified_findings_report") {
|
|
1546
|
+
throw new Error(`unsupported dynamic reduction: ${input.reduction.type}`);
|
|
1547
|
+
}
|
|
1548
|
+
const findings = parseFindingsFromSelector(input.reduction.findingsFrom, input.state);
|
|
1549
|
+
const verifications = parseVerificationsFromSelector(input.reduction.verificationsFrom, input.state);
|
|
1550
|
+
const nodeDir = path.join(input.runDir, input.task.id);
|
|
1551
|
+
await mkdir(nodeDir, { recursive: true });
|
|
1552
|
+
const refutedFindingsPath = path.join(nodeDir, input.reduction.refutedFindingsArtifactName);
|
|
1553
|
+
const { report, refutedFindings } = buildVerifiedFindingsReport({
|
|
1554
|
+
findings,
|
|
1555
|
+
verifications,
|
|
1556
|
+
refutedFindingsRef: refutedFindingsPath.startsWith(input.runDir)
|
|
1557
|
+
? repoRelativePath(input.runDir, refutedFindingsPath)
|
|
1558
|
+
: refutedFindingsPath,
|
|
1559
|
+
});
|
|
1560
|
+
await writeFile(refutedFindingsPath, `${JSON.stringify(refutedFindings, null, 2)}\n`, "utf-8");
|
|
1561
|
+
return {
|
|
1562
|
+
ok: true,
|
|
1563
|
+
stdout: JSON.stringify(report),
|
|
1564
|
+
stderr: "",
|
|
1565
|
+
failureCategory: "success",
|
|
1566
|
+
durationMs: Date.now() - started,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
async function notifyRunObserver(observer, event, state) {
|
|
1570
|
+
try {
|
|
1571
|
+
await observer?.[event]?.(state);
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
// Observers are derived views; they must not affect canonical DAG execution.
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
async function notifyNodeObserver(observer, event, nodeId, state, chunk) {
|
|
1578
|
+
try {
|
|
1579
|
+
if (event === "onNodeOutput") {
|
|
1580
|
+
await observer?.onNodeOutput?.(nodeId, chunk ?? "", state);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
await observer?.[event]?.(nodeId, state);
|
|
1584
|
+
}
|
|
1585
|
+
catch {
|
|
1586
|
+
// Observers are derived views; they must not affect canonical DAG execution.
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function finalizeTerminalRunStatus(state, taskCount) {
|
|
1590
|
+
const finishedCount = Object.values(state.nodes).filter((n) => n.status === "FINISHED").length;
|
|
1591
|
+
const errorCount = Object.values(state.nodes).filter((n) => n.status === "ERROR").length;
|
|
1592
|
+
const conditionSkippedCount = Object.values(state.nodes).filter((n) => n.status === "SKIPPED" && isConditionSkippedReason(n.skippedReason)).length;
|
|
1593
|
+
if (errorCount === 0 && finishedCount + conditionSkippedCount === taskCount) {
|
|
1594
|
+
state.status = "finished";
|
|
1595
|
+
}
|
|
1596
|
+
else if (finishedCount > 0) {
|
|
1597
|
+
state.status = "partial_failed";
|
|
1598
|
+
}
|
|
1599
|
+
else {
|
|
1600
|
+
state.status = "failed";
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
async function stopCursorWorkerIfRunning() {
|
|
1604
|
+
const { isCursorWorkerRunning, stopCursorWorker } = await import("../../executors/cursor-worker-client.js");
|
|
1605
|
+
if (isCursorWorkerRunning()) {
|
|
1606
|
+
await stopCursorWorker();
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
function isWriteGuardedAgentExecutor(task) {
|
|
1610
|
+
return (task.executor === "cursor" ||
|
|
1611
|
+
(task.executor === "pi" && task.toolProfile === "write"));
|
|
1612
|
+
}
|
|
1613
|
+
function concurrentSiblingWriteSetsForNode(rankWriteGuardedNodeIds, nodeId, tasksById) {
|
|
1614
|
+
if (rankWriteGuardedNodeIds.length <= 1)
|
|
1615
|
+
return [];
|
|
1616
|
+
return rankWriteGuardedNodeIds
|
|
1617
|
+
.filter((id) => id !== nodeId)
|
|
1618
|
+
.map((id) => tasksById.get(id)?.writeSet ?? [])
|
|
1619
|
+
.filter((writeSet) => writeSet.length > 0);
|
|
1620
|
+
}
|
|
1621
|
+
function buildRankAwareExecuteNode(input) {
|
|
1622
|
+
if (input.customExecuteNode) {
|
|
1623
|
+
return input.baseExecuteNode;
|
|
1624
|
+
}
|
|
1625
|
+
return async (nodeInput) => {
|
|
1626
|
+
if (!isWriteGuardedAgentExecutor(nodeInput.task) || !nodeInput.model) {
|
|
1627
|
+
return input.baseExecuteNode(nodeInput);
|
|
1628
|
+
}
|
|
1629
|
+
const siblingWriteSets = nodeInput.task.writePolicy === "exclusive"
|
|
1630
|
+
? concurrentSiblingWriteSetsForNode(input.rankWriteGuardedNodeIds, nodeInput.task.id, input.tasksById)
|
|
1631
|
+
: [];
|
|
1632
|
+
const meta = {
|
|
1633
|
+
...input.meta,
|
|
1634
|
+
concurrentSiblingWriteSets: siblingWriteSets.length > 0 ? siblingWriteSets : undefined,
|
|
1635
|
+
writeGuardAttribution: siblingWriteSets.length > 0 ? "best-effort" : "per-node",
|
|
1636
|
+
};
|
|
1637
|
+
if (nodeInput.task.executor === "pi") {
|
|
1638
|
+
return executeDagPiNode({ ...nodeInput, model: nodeInput.model }, meta);
|
|
1639
|
+
}
|
|
1640
|
+
return executeDagCursorNode({ ...nodeInput, model: nodeInput.model }, meta);
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
function shouldSkipNode(node, task, nodes) {
|
|
1644
|
+
if (!node || node.status !== "PENDING")
|
|
1645
|
+
return false;
|
|
1646
|
+
return task.depends_on.some((depId) => {
|
|
1647
|
+
const dep = nodes[depId];
|
|
1648
|
+
return !dep || dep.status === "ERROR" || dep.status === "SKIPPED";
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
function isConditionSkippedReason(reason) {
|
|
1652
|
+
return Boolean(reason?.startsWith("condition "));
|
|
1653
|
+
}
|
|
1654
|
+
function conditionSkippedByAncestor(task, nodes) {
|
|
1655
|
+
return task.depends_on.some((depId) => isConditionSkippedReason(nodes[depId]?.skippedReason));
|
|
1656
|
+
}
|
|
1657
|
+
async function mapConcurrent(items, limit, fn) {
|
|
1658
|
+
const executing = new Set();
|
|
1659
|
+
for (const item of items) {
|
|
1660
|
+
const job = fn(item).finally(() => executing.delete(job));
|
|
1661
|
+
executing.add(job);
|
|
1662
|
+
if (executing.size >= limit) {
|
|
1663
|
+
await Promise.race(executing);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
await Promise.all(executing);
|
|
1667
|
+
}
|
|
1668
|
+
async function writeNodeSkillArtifacts(runDir, nodeId, resolvedSkills) {
|
|
1669
|
+
if (resolvedSkills.length === 0)
|
|
1670
|
+
return;
|
|
1671
|
+
const nodeDir = path.join(runDir, nodeId);
|
|
1672
|
+
await mkdir(nodeDir, { recursive: true });
|
|
1673
|
+
await writeFile(path.join(nodeDir, "skills.json"), `${JSON.stringify({ resolvedSkills }, null, 2)}\n`, "utf-8");
|
|
1674
|
+
}
|
|
1675
|
+
async function appendNodeLog(runDir, nodeId, record) {
|
|
1676
|
+
await writeFile(path.join(runDir, `${nodeId}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
1677
|
+
}
|