@tea-agent/loop-agent 0.2.1 → 0.4.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 +91 -87
- package/CHANGELOG.md +89 -52
- package/README.md +195 -180
- package/bin/agent-worker.js +22 -0
- package/bin/loop-agent.js +21 -21
- package/dist/application/dag/args.js +6 -0
- package/dist/application/dag/generate-task-dag.js +2 -0
- package/dist/application/dag/run-dag.js +3 -0
- package/dist/application/dag/validate-dag.js +40 -0
- package/dist/cli/command-definitions.js +2 -2
- package/dist/cli/program.js +24 -4
- package/dist/commands/init.js +1011 -459
- package/dist/commands/loop-benchmark.js +11 -11
- package/dist/commands/pi-reuse-benchmark.js +16 -16
- package/dist/executors/cursor-executor.js +1 -1
- package/dist/executors/dag-pi-executor.js +8 -1
- package/dist/task/runtime.js +27 -27
- package/dist/worker/cli.js +119 -0
- package/dist/worker/loop-agent/command-result.js +1 -0
- package/dist/worker/loop-agent/loop-agent-client.js +105 -0
- package/dist/worker/loop-agent/parse-json.js +14 -0
- package/dist/worker/materialize/harness-task-materializer.js +157 -0
- package/dist/worker/pool/failure-routing.js +98 -0
- package/dist/worker/pool/run-store.js +117 -0
- package/dist/worker/pool/types.js +1 -0
- package/dist/worker/preflight.js +108 -0
- package/dist/worker/profile-mapping.js +76 -0
- package/dist/worker/progress-reporter.js +81 -0
- package/dist/worker/report/morning-report.js +69 -0
- package/dist/worker/repos/repo-resolver.js +23 -0
- package/dist/worker/run-task/run-task.js +359 -0
- package/dist/worker/runner/run-ready.js +216 -0
- package/dist/worker/task-graph/acceptance-schema.js +25 -0
- package/dist/worker/task-graph/ready-queue.js +23 -0
- package/dist/worker/task-graph/task-graph-schema.js +28 -0
- package/dist/worker/task-graph/types.js +1 -0
- package/dist/worker/task-graph/validate.js +188 -0
- package/dist/worker/task-spec/complexity-mapping.js +8 -0
- package/dist/worker/task-spec/schema.js +116 -0
- package/dist/worker/task-spec/types.js +1 -0
- package/dist/worker/task-spec/validate.js +352 -0
- package/dist/workflows/dag/canvas-observer.js +275 -275
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +2 -1
- package/dist/workflows/dag/dynamic-runtime/map.js +1 -0
- package/dist/workflows/dag/init-hybrid.js +3 -3
- package/dist/workflows/dag/skills.js +3 -3
- package/dist/workflows/dag/types.js +2 -0
- package/dist/workflows/dynamic/compile.js +11 -0
- package/dist/workflows/dynamic/spec.js +1 -0
- package/docs/README.md +72 -65
- package/docs/agent-dag-recovery-playbook.md +184 -184
- package/docs/agent-dag-runner.md +42 -40
- package/docs/architecture/runtime-boundaries.md +147 -147
- package/docs/cursor-executor-usage.md +25 -25
- package/docs/decisions/README.md +3 -3
- package/docs/design/README.md +36 -36
- package/docs/development-principles.md +73 -71
- package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
- package/docs/exec-plans/README.md +6 -6
- package/docs/exec-plans/active/README.md +7 -10
- package/docs/exec-plans/completed/README.md +19 -9
- package/docs/feature-workflow.md +186 -186
- package/docs/harness-methodology-debugging.md +153 -153
- package/docs/harness-methodology-tdd.md +130 -130
- package/docs/harness-methodology-verification.md +27 -27
- package/docs/init-surface.manifest.json +175 -0
- package/docs/loop-agent-harness.md +42 -42
- package/docs/production-readiness.md +96 -96
- package/docs/progress/README.md +3 -3
- package/docs/reports/README.md +5 -5
- package/docs/skills/README.md +6 -0
- package/docs/skills/vetted-skill-registry.md +26 -0
- package/docs/templates/adr.md +60 -60
- package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
- package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
- package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
- package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
- package/docs/templates/agent-dag-report.schema.json +454 -454
- package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
- package/docs/templates/agent-dag.base.json +195 -195
- package/docs/templates/agent-dag.final-verification.json +190 -190
- package/docs/templates/agent-dag.schema.json +316 -316
- package/docs/templates/agent-dag.supervised-implementation.json +500 -500
- package/docs/templates/exec-plan.md +64 -64
- package/docs/templates/feature-spec.md +53 -53
- package/docs/templates/hybrid-dag.json +193 -193
- package/docs/templates/init-evolution-review.md +33 -0
- package/docs/templates/production-readiness-checklist.md +57 -57
- package/docs/templates/progress-log.md +17 -17
- package/docs/templates/project-start-checklist.md +9 -9
- package/docs/templates/qa-report.md +48 -48
- package/docs/templates/sprint-contract.md +29 -29
- package/docs/verification-matrix.md +41 -41
- package/examples/decision-gate-agent-dag.json +123 -123
- package/examples/example-dag.json +51 -51
- package/examples/hybrid-loop-agent-dag.json +194 -194
- package/harness.json +94 -92
- package/package.json +66 -62
- package/skills/ai-engineering-context/SKILL.md +48 -48
- package/skills/code-review-core/SKILL.md +20 -0
- package/skills/codebase-scout/SKILL.md +19 -0
- package/skills/init-capability-evolution/SKILL.md +69 -0
- package/skills/loop-agent/SKILL.md +147 -145
- package/skills/loop-agent/references/README.md +67 -67
- package/skills/loop-agent/references/command-reference.md +403 -357
- package/skills/loop-agent/references/harness-policy.md +259 -258
- package/skills/loop-agent/references/hybrid-dag.md +216 -216
- package/skills/loop-agent/references/learned/README.md +21 -21
- package/skills/loop-agent/references/long-running-loop.md +59 -59
- package/skills/loop-agent/references/model-routing.md +36 -36
- package/skills/loop-agent/references/multi-worktree.md +54 -54
- package/skills/loop-agent/references/one-shot-runs.md +85 -85
- package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
- package/skills/loop-agent/references/pi-prompt.md +23 -23
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
- package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
- package/skills/loop-agent/references/task-workflow.md +84 -84
- package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
- package/skills/requesting-code-review/SKILL.md +101 -101
- package/skills/requesting-code-review/code-reviewer.md +168 -168
- package/skills/systematic-debugging/CREATION-LOG.md +119 -119
- package/skills/systematic-debugging/SKILL.md +296 -296
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
- package/skills/systematic-debugging/condition-based-waiting.md +115 -115
- package/skills/systematic-debugging/defense-in-depth.md +122 -122
- package/skills/systematic-debugging/find-polluter.sh +63 -63
- package/skills/systematic-debugging/root-cause-tracing.md +169 -169
- package/skills/systematic-debugging/test-academic.md +14 -14
- package/skills/systematic-debugging/test-pressure-1.md +58 -58
- package/skills/systematic-debugging/test-pressure-2.md +68 -68
- package/skills/systematic-debugging/test-pressure-3.md +69 -69
- package/skills/test-driven-development/SKILL.md +20 -0
- package/skills/verification-before-completion/SKILL.md +154 -154
- package/skills/webapp-testing/SKILL.md +19 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { formatDuration, noopProgressReporter } from "../progress-reporter.js";
|
|
5
|
+
import { materializeTaskSpec, } from "../materialize/harness-task-materializer.js";
|
|
6
|
+
import { preflightTargetRepo } from "../preflight.js";
|
|
7
|
+
export const DEFAULT_RUN_DAG_TIMEOUT_MS = 1_800_000;
|
|
8
|
+
export const MAX_WORKER_TIMEOUT_MS = 7_200_000;
|
|
9
|
+
export async function runTaskSpec(options) {
|
|
10
|
+
const now = options.now ?? new Date();
|
|
11
|
+
const workerRunId = options.workerRunId ?? buildWorkerRunId(options.taskSpec.id, now);
|
|
12
|
+
const commands = [];
|
|
13
|
+
const client = new RecordingRunTaskClient(options.client, commands);
|
|
14
|
+
const progress = options.progress ?? noopProgressReporter;
|
|
15
|
+
if (options.preflight !== false) {
|
|
16
|
+
progress.step("preflight: version + inspect + docs-audit + git-status" + (options.preflight && typeof options.preflight === "object" && options.preflight.runCheckRepo ? " + check-repo" : ""));
|
|
17
|
+
const preflightStartedAt = Date.now();
|
|
18
|
+
const preflight = await preflightTargetRepo({
|
|
19
|
+
repoRoot: options.repoRoot,
|
|
20
|
+
client,
|
|
21
|
+
...(typeof options.preflight === "object" ? options.preflight : {}),
|
|
22
|
+
});
|
|
23
|
+
if (!preflight.ok) {
|
|
24
|
+
progress.note(`preflight FAILED: ${preflight.code}`);
|
|
25
|
+
throw new Error(`target repo preflight failed: ${preflight.code}: ${preflight.message}`);
|
|
26
|
+
}
|
|
27
|
+
progress.step(`preflight ok in ${formatDuration(Date.now() - preflightStartedAt)}`);
|
|
28
|
+
}
|
|
29
|
+
progress.step("materialize harness task");
|
|
30
|
+
const materializeStartedAt = Date.now();
|
|
31
|
+
const materializeManifest = await materializeTaskSpec({
|
|
32
|
+
repoRoot: options.repoRoot,
|
|
33
|
+
taskSpec: options.taskSpec,
|
|
34
|
+
taskSpecPath: options.taskSpecPath,
|
|
35
|
+
client,
|
|
36
|
+
now,
|
|
37
|
+
});
|
|
38
|
+
progress.step(`materialized ${materializeManifest.harnessTaskId} in ${formatDuration(Date.now() - materializeStartedAt)}`);
|
|
39
|
+
const taskArtifactsDir = path.join(options.repoRoot, ".harness", "tasks", materializeManifest.harnessTaskId, "artifacts");
|
|
40
|
+
await mkdir(taskArtifactsDir, { recursive: true });
|
|
41
|
+
const dagPath = path.join(taskArtifactsDir, `${workerRunId}-dag.json`);
|
|
42
|
+
const runRecordPath = path.join(taskArtifactsDir, "worker-run-record.json");
|
|
43
|
+
progress.step("dag run-task: generate DAG");
|
|
44
|
+
const dagGenStartedAt = Date.now();
|
|
45
|
+
await runRequiredCommand(options.repoRoot, client, "dag-run-task", [
|
|
46
|
+
"dag",
|
|
47
|
+
"run-task",
|
|
48
|
+
materializeManifest.harnessTaskId,
|
|
49
|
+
"--profile",
|
|
50
|
+
materializeManifest.loopAgentProfile,
|
|
51
|
+
"--strict-models",
|
|
52
|
+
"--output",
|
|
53
|
+
dagPath,
|
|
54
|
+
"--cwd",
|
|
55
|
+
options.repoRoot,
|
|
56
|
+
...noCursorArgs(options.taskSpec),
|
|
57
|
+
]);
|
|
58
|
+
progress.step(`dag generated in ${formatDuration(Date.now() - dagGenStartedAt)}`);
|
|
59
|
+
if (options.piModel) {
|
|
60
|
+
progress.step(`pi-model override: rewriting executorModels.pi → ${options.piModel}`);
|
|
61
|
+
await applyPiModelOverride(dagPath, options.piModel);
|
|
62
|
+
}
|
|
63
|
+
progress.step("dag validate");
|
|
64
|
+
const validateStartedAt = Date.now();
|
|
65
|
+
await runRequiredCommand(options.repoRoot, client, "dag-validate", [
|
|
66
|
+
"dag",
|
|
67
|
+
"validate",
|
|
68
|
+
"--dag",
|
|
69
|
+
dagPath,
|
|
70
|
+
...strictModelsArgs(options.piModel),
|
|
71
|
+
"--strict-governance",
|
|
72
|
+
"--spine-task",
|
|
73
|
+
materializeManifest.harnessTaskId,
|
|
74
|
+
...forbidCursorArgs(options.taskSpec),
|
|
75
|
+
]);
|
|
76
|
+
progress.step(`dag validated in ${formatDuration(Date.now() - validateStartedAt)}`);
|
|
77
|
+
progress.step("run-dag: executing DAG nodes (this is the long step)");
|
|
78
|
+
const runDagStartedAt = Date.now();
|
|
79
|
+
await runRequiredCommand(options.repoRoot, client, "run-dag", [
|
|
80
|
+
"run-dag",
|
|
81
|
+
"--dag",
|
|
82
|
+
dagPath,
|
|
83
|
+
"--cwd",
|
|
84
|
+
options.repoRoot,
|
|
85
|
+
"--run-id",
|
|
86
|
+
workerRunId,
|
|
87
|
+
...maxConcurrentArgs(options.taskSpec),
|
|
88
|
+
...noCursorArgs(options.taskSpec),
|
|
89
|
+
], true, resolveRunDagTimeoutMs(options.taskSpec));
|
|
90
|
+
progress.step(`run-dag finished in ${formatDuration(Date.now() - runDagStartedAt)}`);
|
|
91
|
+
const reportJson = await client.run([
|
|
92
|
+
"dag",
|
|
93
|
+
"report",
|
|
94
|
+
"--run-id",
|
|
95
|
+
workerRunId,
|
|
96
|
+
"--lifecycle",
|
|
97
|
+
"all",
|
|
98
|
+
"--json",
|
|
99
|
+
], {
|
|
100
|
+
cwd: options.repoRoot,
|
|
101
|
+
artifactName: "dag-report-json",
|
|
102
|
+
expectJson: true,
|
|
103
|
+
});
|
|
104
|
+
const reportDecision = decideFromReport(workerRunId, reportJson);
|
|
105
|
+
await client.run([
|
|
106
|
+
"dag",
|
|
107
|
+
"report",
|
|
108
|
+
"--run-id",
|
|
109
|
+
workerRunId,
|
|
110
|
+
"--lifecycle",
|
|
111
|
+
"all",
|
|
112
|
+
"--markdown",
|
|
113
|
+
], {
|
|
114
|
+
cwd: options.repoRoot,
|
|
115
|
+
artifactName: "dag-report-markdown",
|
|
116
|
+
});
|
|
117
|
+
const status = reportDecision.succeeded ? "succeeded" : "failed";
|
|
118
|
+
progress.step(`report decision: ${status} (${reportDecision.reason}${reportDecision.runStatus ? `, status=${reportDecision.runStatus}` : ""})`);
|
|
119
|
+
const failureArtifacts = reportDecision.succeeded
|
|
120
|
+
? undefined
|
|
121
|
+
: await collectFailureArtifacts({
|
|
122
|
+
client,
|
|
123
|
+
repoRoot: options.repoRoot,
|
|
124
|
+
workerRunId,
|
|
125
|
+
taskArtifactsDir,
|
|
126
|
+
});
|
|
127
|
+
if (reportDecision.succeeded) {
|
|
128
|
+
progress.step("promote + closeout");
|
|
129
|
+
await runRequiredCommand(options.repoRoot, client, "promote-run", ["promote-run", materializeManifest.harnessTaskId, "--run-id", workerRunId], true);
|
|
130
|
+
await runRequiredCommand(options.repoRoot, client, "closeout-task", ["closeout", "task", materializeManifest.harnessTaskId], true);
|
|
131
|
+
}
|
|
132
|
+
const record = {
|
|
133
|
+
schemaVersion: 1,
|
|
134
|
+
status,
|
|
135
|
+
workerRunId,
|
|
136
|
+
businessId: options.taskSpec.id,
|
|
137
|
+
harnessTaskId: materializeManifest.harnessTaskId,
|
|
138
|
+
featureId: options.taskSpec.feature_id,
|
|
139
|
+
loopAgentProfile: materializeManifest.loopAgentProfile,
|
|
140
|
+
dagPath,
|
|
141
|
+
runRecordPath,
|
|
142
|
+
materializeManifest,
|
|
143
|
+
reportDecision,
|
|
144
|
+
commands,
|
|
145
|
+
...(failureArtifacts ? { failureArtifacts } : {}),
|
|
146
|
+
};
|
|
147
|
+
await writeFile(runRecordPath, `${JSON.stringify(record, null, 2)}\n`, "utf-8");
|
|
148
|
+
return {
|
|
149
|
+
status,
|
|
150
|
+
workerRunId,
|
|
151
|
+
businessId: options.taskSpec.id,
|
|
152
|
+
harnessTaskId: materializeManifest.harnessTaskId,
|
|
153
|
+
runRecordPath,
|
|
154
|
+
dagPath,
|
|
155
|
+
reportDecision,
|
|
156
|
+
...(failureArtifacts ? { failureArtifacts } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function buildWorkerRunId(businessId, now) {
|
|
160
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
161
|
+
const hash = createHash("sha256")
|
|
162
|
+
.update(`${businessId}:${now.toISOString()}`)
|
|
163
|
+
.digest("hex")
|
|
164
|
+
.slice(0, 6);
|
|
165
|
+
return `wr-${date}-${businessId}-${hash}`;
|
|
166
|
+
}
|
|
167
|
+
async function runRequiredCommand(repoRoot, client, artifactName, args, expectJson = true, timeoutMs) {
|
|
168
|
+
const result = await client.run(args, {
|
|
169
|
+
cwd: repoRoot,
|
|
170
|
+
artifactName,
|
|
171
|
+
expectJson,
|
|
172
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
173
|
+
});
|
|
174
|
+
if (!result.ok) {
|
|
175
|
+
throw new Error(`loop-agent command failed: ${args.join(" ")}`);
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
function resolveRunDagTimeoutMs(taskSpec) {
|
|
180
|
+
return Math.min(taskSpec.worker.timeout_ms ?? DEFAULT_RUN_DAG_TIMEOUT_MS, MAX_WORKER_TIMEOUT_MS);
|
|
181
|
+
}
|
|
182
|
+
async function collectFailureArtifacts(input) {
|
|
183
|
+
const doctor = await input.client.run(["dag", "doctor", "--run-id", input.workerRunId, "--markdown"], {
|
|
184
|
+
cwd: input.repoRoot,
|
|
185
|
+
artifactName: "dag-doctor-markdown",
|
|
186
|
+
});
|
|
187
|
+
const closeoutDraftPath = path.join(input.repoRoot, ".task-pool", "failure-handoffs", `${input.workerRunId}-failure-closeout-draft.md`);
|
|
188
|
+
await input.client.run([
|
|
189
|
+
"dag",
|
|
190
|
+
"closeout-draft",
|
|
191
|
+
"--run-id",
|
|
192
|
+
input.workerRunId,
|
|
193
|
+
"--output",
|
|
194
|
+
closeoutDraftPath,
|
|
195
|
+
], {
|
|
196
|
+
cwd: input.repoRoot,
|
|
197
|
+
artifactName: "dag-closeout-draft",
|
|
198
|
+
});
|
|
199
|
+
const reportMarkdown = input.client instanceof RecordingRunTaskClient
|
|
200
|
+
? input.client.findArtifact("dag-report-markdown")
|
|
201
|
+
: undefined;
|
|
202
|
+
return {
|
|
203
|
+
reportMarkdownArtifactPath: reportMarkdown?.result.artifacts.stdoutPath ?? "",
|
|
204
|
+
doctorMarkdownArtifactPath: doctor.artifacts.stdoutPath,
|
|
205
|
+
closeoutDraftPath,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function decideFromReport(workerRunId, result) {
|
|
209
|
+
if (!result.ok || !result.json) {
|
|
210
|
+
return {
|
|
211
|
+
succeeded: false,
|
|
212
|
+
reason: "report-json-unavailable",
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const runs = readObjectArray(result.json, "runs");
|
|
216
|
+
const run = runs.find((candidate) => readString(candidate, "runId") === workerRunId) ??
|
|
217
|
+
runs[0];
|
|
218
|
+
if (!run) {
|
|
219
|
+
return {
|
|
220
|
+
succeeded: false,
|
|
221
|
+
reason: "report-run-missing",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const runStatus = readString(run, "status");
|
|
225
|
+
const nodes = readObjectArray(run, "nodes");
|
|
226
|
+
const failedNode = nodes.find((node) => {
|
|
227
|
+
const status = readString(node, "status");
|
|
228
|
+
const failureCategory = readString(node, "failureCategory");
|
|
229
|
+
return (status === "ERROR" ||
|
|
230
|
+
status === "SKIPPED" ||
|
|
231
|
+
(Boolean(failureCategory) && failureCategory !== "success"));
|
|
232
|
+
});
|
|
233
|
+
if (runStatus !== "completed" && runStatus !== "finished") {
|
|
234
|
+
return {
|
|
235
|
+
succeeded: false,
|
|
236
|
+
reason: "run-not-completed",
|
|
237
|
+
runStatus,
|
|
238
|
+
primaryFailure: readProperty(run, "primaryFailure"),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (failedNode) {
|
|
242
|
+
return {
|
|
243
|
+
succeeded: false,
|
|
244
|
+
reason: "node-failure",
|
|
245
|
+
runStatus,
|
|
246
|
+
primaryFailure: readProperty(run, "primaryFailure") ?? failedNode,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
succeeded: true,
|
|
251
|
+
reason: "report-completed",
|
|
252
|
+
runStatus,
|
|
253
|
+
primaryFailure: readProperty(run, "primaryFailure"),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
class RecordingRunTaskClient {
|
|
257
|
+
delegate;
|
|
258
|
+
records;
|
|
259
|
+
constructor(delegate, records) {
|
|
260
|
+
this.delegate = delegate;
|
|
261
|
+
this.records = records;
|
|
262
|
+
}
|
|
263
|
+
async run(args, options) {
|
|
264
|
+
const result = await this.delegate.run(args, options);
|
|
265
|
+
this.records.push({
|
|
266
|
+
name: commandRecordName(args, options.artifactName),
|
|
267
|
+
artifactName: options.artifactName,
|
|
268
|
+
args,
|
|
269
|
+
result: {
|
|
270
|
+
ok: result.ok,
|
|
271
|
+
exitCode: result.exitCode,
|
|
272
|
+
durationMs: result.durationMs,
|
|
273
|
+
timedOut: result.timedOut,
|
|
274
|
+
artifacts: result.artifacts,
|
|
275
|
+
...(result.parseFailure ? { parseFailure: result.parseFailure } : {}),
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
async runExternal(command, args, options) {
|
|
281
|
+
const result = await this.delegate.runExternal(command, args, options);
|
|
282
|
+
this.records.push({
|
|
283
|
+
name: options.artifactName,
|
|
284
|
+
artifactName: options.artifactName,
|
|
285
|
+
args: [command, ...args],
|
|
286
|
+
result: {
|
|
287
|
+
ok: result.ok,
|
|
288
|
+
exitCode: result.exitCode,
|
|
289
|
+
durationMs: result.durationMs,
|
|
290
|
+
timedOut: result.timedOut,
|
|
291
|
+
artifacts: result.artifacts,
|
|
292
|
+
...(result.parseFailure ? { parseFailure: result.parseFailure } : {}),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
findArtifact(artifactName) {
|
|
298
|
+
return this.records.find((record) => record.artifactName === artifactName);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
function commandRecordName(args, artifactName) {
|
|
302
|
+
if (args[0] === "new-task")
|
|
303
|
+
return "new-task";
|
|
304
|
+
return artifactName;
|
|
305
|
+
}
|
|
306
|
+
function noCursorArgs(taskSpec) {
|
|
307
|
+
return taskSpec.loop_agent.no_cursor ? ["--no-cursor"] : [];
|
|
308
|
+
}
|
|
309
|
+
function forbidCursorArgs(taskSpec) {
|
|
310
|
+
return taskSpec.loop_agent.no_cursor ? ["--forbid-executor", "cursor"] : [];
|
|
311
|
+
}
|
|
312
|
+
function maxConcurrentArgs(taskSpec) {
|
|
313
|
+
return ["--max-concurrent", String(taskSpec.loop_agent.max_concurrent)];
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* When a smoke `piModel` override is active, drop `--strict-models` so the
|
|
317
|
+
* non-canonical `executorModels.pi` rewrite passes `dag validate`. Governance,
|
|
318
|
+
* forbidden-executor and spine audits are still enforced by their own flags.
|
|
319
|
+
*/
|
|
320
|
+
function strictModelsArgs(piModel) {
|
|
321
|
+
return piModel ? [] : ["--strict-models"];
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Rewrite `executorModels.pi.{LOW,MED,HIGH}` in an already-generated DAG JSON
|
|
325
|
+
* so every pi executor node resolves to the smoke override model. Cursor / shell
|
|
326
|
+
* slots are left untouched.
|
|
327
|
+
*/
|
|
328
|
+
export async function applyPiModelOverride(dagPath, piModel) {
|
|
329
|
+
const raw = await readFile(dagPath, "utf-8");
|
|
330
|
+
const spec = JSON.parse(raw);
|
|
331
|
+
spec.executorModels = {
|
|
332
|
+
...(spec.executorModels?.cursor ? { cursor: spec.executorModels.cursor } : {}),
|
|
333
|
+
pi: {
|
|
334
|
+
LOW: piModel,
|
|
335
|
+
MED: piModel,
|
|
336
|
+
HIGH: piModel,
|
|
337
|
+
},
|
|
338
|
+
};
|
|
339
|
+
await writeFile(dagPath, `${JSON.stringify(spec, null, 2)}\n`, "utf-8");
|
|
340
|
+
}
|
|
341
|
+
function readObjectArray(value, key) {
|
|
342
|
+
if (!value || typeof value !== "object")
|
|
343
|
+
return [];
|
|
344
|
+
const child = value[key];
|
|
345
|
+
if (!Array.isArray(child))
|
|
346
|
+
return [];
|
|
347
|
+
return child.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
|
|
348
|
+
}
|
|
349
|
+
function readString(value, key) {
|
|
350
|
+
if (!value || typeof value !== "object")
|
|
351
|
+
return undefined;
|
|
352
|
+
const child = value[key];
|
|
353
|
+
return typeof child === "string" ? child : undefined;
|
|
354
|
+
}
|
|
355
|
+
function readProperty(value, key) {
|
|
356
|
+
if (!value || typeof value !== "object")
|
|
357
|
+
return undefined;
|
|
358
|
+
return value[key];
|
|
359
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { deriveFailureRoute } from "../pool/failure-routing.js";
|
|
6
|
+
import { findRunByWorkerRunId, getTaskPoolRoot, readAllTaskPoolStates, recordTaskPoolRun, writeTaskPoolState, } from "../pool/run-store.js";
|
|
7
|
+
import { runTaskSpec, } from "../run-task/run-task.js";
|
|
8
|
+
import { formatDuration, noopProgressReporter } from "../progress-reporter.js";
|
|
9
|
+
import { computeReadyQueue } from "../task-graph/ready-queue.js";
|
|
10
|
+
import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
|
|
11
|
+
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
12
|
+
export async function runReadyTasks(options) {
|
|
13
|
+
const now = options.now ?? new Date();
|
|
14
|
+
const startedAt = now.toISOString();
|
|
15
|
+
const batchRunId = options.batchRunId ?? buildBatchRunId(now);
|
|
16
|
+
const graph = await loadTaskGraph(options.featureDir);
|
|
17
|
+
const states = await readAllTaskPoolStates(options.repoRoot);
|
|
18
|
+
const readyTaskIds = computeReadyQueue(graph, toGraphState(states)).filter((taskId) => isPoolReady(states[taskId]));
|
|
19
|
+
const limitedTaskIds = readyTaskIds.slice(0, options.limit ?? readyTaskIds.length);
|
|
20
|
+
const tasks = [];
|
|
21
|
+
const runner = options.runTask ?? runTaskSpec;
|
|
22
|
+
const progress = options.progress ?? noopProgressReporter;
|
|
23
|
+
const total = limitedTaskIds.length;
|
|
24
|
+
let index = 0;
|
|
25
|
+
const batchStartedAt = Date.now();
|
|
26
|
+
progress.batch(`batch ${batchRunId}: feature=${graph.feature_id}, ${total} task${total === 1 ? "" : "s"} ready → starting`);
|
|
27
|
+
for (const taskId of limitedTaskIds) {
|
|
28
|
+
index += 1;
|
|
29
|
+
const node = graph.nodes.find((candidate) => candidate.id === taskId);
|
|
30
|
+
const taskSpecPath = path.join(options.featureDir, "tasks", node?.task ?? `${taskId}.yaml`);
|
|
31
|
+
const taskSpec = await loadTaskSpec(taskSpecPath);
|
|
32
|
+
const workerRunId = options.workerRunIdForTask?.(taskId, taskSpec) ??
|
|
33
|
+
buildStableWorkerRunId(taskId, taskSpec, now);
|
|
34
|
+
if (workerRunId) {
|
|
35
|
+
const existing = await findRunByWorkerRunId(options.repoRoot, workerRunId);
|
|
36
|
+
if (existing) {
|
|
37
|
+
progress.task(`task ${index}/${total} ${taskId}: reuse existing run ${workerRunId}`);
|
|
38
|
+
tasks.push({
|
|
39
|
+
taskId,
|
|
40
|
+
workerRunId,
|
|
41
|
+
status: "reused",
|
|
42
|
+
runRecordPath: existing.runRecordPath,
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const taskStartedAt = Date.now();
|
|
48
|
+
progress.task(`task ${index}/${total} ${taskId} "${taskSpec.title}" (${workerRunId})`);
|
|
49
|
+
progress.step(`preflight → materialize → dag-run-task → validate → run-dag → report`);
|
|
50
|
+
let result;
|
|
51
|
+
try {
|
|
52
|
+
await writeTaskPoolState(options.repoRoot, {
|
|
53
|
+
taskId,
|
|
54
|
+
status: "Running",
|
|
55
|
+
updatedAt: new Date().toISOString(),
|
|
56
|
+
workerRunId,
|
|
57
|
+
});
|
|
58
|
+
result = await runner({
|
|
59
|
+
repoRoot: options.repoRoot,
|
|
60
|
+
taskSpec,
|
|
61
|
+
taskSpecPath,
|
|
62
|
+
client: options.client,
|
|
63
|
+
now,
|
|
64
|
+
workerRunId,
|
|
65
|
+
preflight: {
|
|
66
|
+
runCheckRepo: options.runCheckRepo,
|
|
67
|
+
...(options.checkRepoCommand ? { checkRepoCommand: options.checkRepoCommand } : {}),
|
|
68
|
+
},
|
|
69
|
+
progress,
|
|
70
|
+
...(options.piModel ? { piModel: options.piModel } : {}),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
const message = errorMessage(error);
|
|
75
|
+
progress.note(`task ${taskId}: ERROR ${message}`);
|
|
76
|
+
try {
|
|
77
|
+
await recordTaskPoolRun({
|
|
78
|
+
repoRoot: options.repoRoot,
|
|
79
|
+
run: {
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
batchRunId,
|
|
82
|
+
workerRunId,
|
|
83
|
+
taskId,
|
|
84
|
+
featureId: taskSpec.feature_id,
|
|
85
|
+
status: "run-error",
|
|
86
|
+
recordedAt: new Date().toISOString(),
|
|
87
|
+
error: message,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch (recordError) {
|
|
92
|
+
tasks.push({
|
|
93
|
+
taskId,
|
|
94
|
+
workerRunId,
|
|
95
|
+
status: "record-error",
|
|
96
|
+
error: errorMessage(recordError),
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
tasks.push({
|
|
101
|
+
taskId,
|
|
102
|
+
workerRunId,
|
|
103
|
+
status: "run-error",
|
|
104
|
+
error: message,
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const failure = deriveFailureRoute(result);
|
|
109
|
+
if (result.status === "succeeded") {
|
|
110
|
+
progress.step(`✓ ${taskId} succeeded in ${formatDuration(Date.now() - taskStartedAt)} (promoted + closed out)`);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
progress.step(`✗ ${taskId} failed in ${formatDuration(Date.now() - taskStartedAt)} — see failure artifacts`);
|
|
114
|
+
}
|
|
115
|
+
const run = {
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
batchRunId,
|
|
118
|
+
workerRunId: result.workerRunId,
|
|
119
|
+
taskId: result.businessId,
|
|
120
|
+
featureId: taskSpec.feature_id,
|
|
121
|
+
status: result.status,
|
|
122
|
+
harnessTaskId: result.harnessTaskId,
|
|
123
|
+
runRecordPath: result.runRecordPath,
|
|
124
|
+
dagPath: result.dagPath,
|
|
125
|
+
recordedAt: new Date().toISOString(),
|
|
126
|
+
...(failure ? { failure } : {}),
|
|
127
|
+
...(result.failureArtifacts ? { failureArtifacts: result.failureArtifacts } : {}),
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
await recordTaskPoolRun({ repoRoot: options.repoRoot, run });
|
|
131
|
+
tasks.push({
|
|
132
|
+
taskId,
|
|
133
|
+
workerRunId: result.workerRunId,
|
|
134
|
+
status: result.status,
|
|
135
|
+
runRecordPath: result.runRecordPath,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
tasks.push({
|
|
140
|
+
taskId,
|
|
141
|
+
workerRunId: result.workerRunId,
|
|
142
|
+
status: "record-error",
|
|
143
|
+
error: errorMessage(error),
|
|
144
|
+
runRecordPath: result.runRecordPath,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const summary = summarize(tasks);
|
|
149
|
+
const batchRunPath = path.join(getTaskPoolRoot(options.repoRoot), "runs", `${batchRunId}.json`);
|
|
150
|
+
const output = {
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
status: summary.recordErrors > 0 || summary.runErrors > 0 ? "failed" : "completed",
|
|
153
|
+
batchRunId,
|
|
154
|
+
featureId: graph.feature_id,
|
|
155
|
+
startedAt,
|
|
156
|
+
summary,
|
|
157
|
+
tasks,
|
|
158
|
+
batchRunPath,
|
|
159
|
+
};
|
|
160
|
+
await mkdir(path.dirname(batchRunPath), { recursive: true });
|
|
161
|
+
await writeFile(batchRunPath, `${JSON.stringify(output, null, 2)}\n`, "utf-8");
|
|
162
|
+
progress.batch(`batch ${batchRunId} ${output.status}: ${summary.succeeded} succeeded, ${summary.failed} failed, ${summary.reused} reused in ${formatDuration(Date.now() - batchStartedAt)} → ${batchRunPath}`);
|
|
163
|
+
return output;
|
|
164
|
+
}
|
|
165
|
+
export function buildBatchRunId(now) {
|
|
166
|
+
return `batch-${now.toISOString().replace(/[-:.]/g, "").slice(0, 15)}`;
|
|
167
|
+
}
|
|
168
|
+
export function buildStableWorkerRunId(taskId, taskSpec, now) {
|
|
169
|
+
const date = now.toISOString().slice(0, 10).replace(/-/g, "");
|
|
170
|
+
const slug = taskId
|
|
171
|
+
.toLowerCase()
|
|
172
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
173
|
+
.replace(/^-+|-+$/g, "");
|
|
174
|
+
const hash = createHash("sha256")
|
|
175
|
+
.update(`${taskSpec.feature_id}:${taskSpec.id}:${taskSpec.title}`)
|
|
176
|
+
.digest("hex")
|
|
177
|
+
.slice(0, 10);
|
|
178
|
+
return `wr-${date}-${slug}-${hash}`;
|
|
179
|
+
}
|
|
180
|
+
async function loadTaskGraph(featureDir) {
|
|
181
|
+
const graphPath = path.join(featureDir, "tasks", "task-graph.yaml");
|
|
182
|
+
return taskGraphSpecSchema.parse(YAML.parse(await readFile(graphPath, "utf-8")));
|
|
183
|
+
}
|
|
184
|
+
async function loadTaskSpec(taskSpecPath) {
|
|
185
|
+
return taskSpecSchema.parse(YAML.parse(await readFile(taskSpecPath, "utf-8")));
|
|
186
|
+
}
|
|
187
|
+
function toGraphState(states) {
|
|
188
|
+
const graphState = {};
|
|
189
|
+
for (const [taskId, state] of Object.entries(states)) {
|
|
190
|
+
if (state.status === "Done")
|
|
191
|
+
graphState[taskId] = "completed";
|
|
192
|
+
if (state.status === "Running" || state.status === "Queued")
|
|
193
|
+
graphState[taskId] = "running";
|
|
194
|
+
if (state.status === "Failed")
|
|
195
|
+
graphState[taskId] = "failed";
|
|
196
|
+
if (state.status === "Blocked")
|
|
197
|
+
graphState[taskId] = "blocked";
|
|
198
|
+
}
|
|
199
|
+
return graphState;
|
|
200
|
+
}
|
|
201
|
+
function isPoolReady(state) {
|
|
202
|
+
return !state || state.status === "Ready";
|
|
203
|
+
}
|
|
204
|
+
function summarize(tasks) {
|
|
205
|
+
return {
|
|
206
|
+
total: tasks.length,
|
|
207
|
+
succeeded: tasks.filter((task) => task.status === "succeeded").length,
|
|
208
|
+
failed: tasks.filter((task) => task.status === "failed").length,
|
|
209
|
+
reused: tasks.filter((task) => task.status === "reused").length,
|
|
210
|
+
recordErrors: tasks.filter((task) => task.status === "record-error").length,
|
|
211
|
+
runErrors: tasks.filter((task) => task.status === "run-error").length,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function errorMessage(error) {
|
|
215
|
+
return error instanceof Error ? error.message : String(error);
|
|
216
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const acceptanceItemSchema = z
|
|
3
|
+
.object({
|
|
4
|
+
id: z.string().min(1),
|
|
5
|
+
title: z.string().min(1),
|
|
6
|
+
priority: z.enum(["must", "should", "could", "wont"]).optional().default("must"),
|
|
7
|
+
type: z.string().min(1),
|
|
8
|
+
given: z.string().min(1),
|
|
9
|
+
when: z.string().min(1),
|
|
10
|
+
then: z.string().min(1),
|
|
11
|
+
verification: z
|
|
12
|
+
.object({
|
|
13
|
+
expected_task_refs: z.array(z.string().min(1)).min(1),
|
|
14
|
+
suggested_tests: z.array(z.string().min(1)).optional().default([]),
|
|
15
|
+
})
|
|
16
|
+
.strict(),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export const acceptanceSpecSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
schema_version: z.literal(1),
|
|
22
|
+
feature_id: z.string().min(1),
|
|
23
|
+
acceptance: z.array(acceptanceItemSchema).min(1),
|
|
24
|
+
})
|
|
25
|
+
.strict();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const TERMINAL_OR_ACTIVE = new Set([
|
|
2
|
+
"running",
|
|
3
|
+
"completed",
|
|
4
|
+
"failed",
|
|
5
|
+
"blocked",
|
|
6
|
+
]);
|
|
7
|
+
export function computeReadyQueue(graph, state) {
|
|
8
|
+
return graph.nodes
|
|
9
|
+
.filter((node) => {
|
|
10
|
+
const ownStatus = getStatus(state[node.id]);
|
|
11
|
+
if (ownStatus && TERMINAL_OR_ACTIVE.has(ownStatus))
|
|
12
|
+
return false;
|
|
13
|
+
return node.depends_on.every((dependencyId) => getStatus(state[dependencyId]) === "completed");
|
|
14
|
+
})
|
|
15
|
+
.map((node) => node.id);
|
|
16
|
+
}
|
|
17
|
+
function getStatus(value) {
|
|
18
|
+
if (!value)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (typeof value === "string")
|
|
21
|
+
return value;
|
|
22
|
+
return value.status;
|
|
23
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { taskSpecTypeSchema } from "../task-spec/schema.js";
|
|
3
|
+
export const taskGraphNodeSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
id: z.string().min(1),
|
|
6
|
+
task: z.string().min(1),
|
|
7
|
+
type: taskSpecTypeSchema,
|
|
8
|
+
depends_on: z.array(z.string().min(1)).optional().default([]),
|
|
9
|
+
})
|
|
10
|
+
.strict();
|
|
11
|
+
export const taskGraphSpecSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
schema_version: z.literal(1),
|
|
14
|
+
feature_id: z.string().min(1),
|
|
15
|
+
nodes: z.array(taskGraphNodeSchema).min(1),
|
|
16
|
+
parallel_policy: z
|
|
17
|
+
.object({
|
|
18
|
+
max_parallel_tasks: z.number().int().positive().optional().default(1),
|
|
19
|
+
disallow_same_file_parallel_writes: z.boolean().optional().default(true),
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
.optional()
|
|
23
|
+
.default({
|
|
24
|
+
max_parallel_tasks: 1,
|
|
25
|
+
disallow_same_file_parallel_writes: true,
|
|
26
|
+
}),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|