@tea-agent/loop-agent 0.8.0 → 0.10.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 +2 -0
- package/CHANGELOG.md +51 -1
- package/README.md +20 -0
- package/dist/application/dag/args.js +9 -2
- package/dist/cli/command-definitions.js +7 -0
- package/dist/cli/program.js +6 -1
- package/dist/commands/dag-reconcile-run.js +118 -0
- package/dist/commands/init.js +12 -3
- package/dist/executors/shell-executor.js +74 -8
- package/dist/governance/manifest-types.js +4 -0
- package/dist/shared/reference-context.js +48 -22
- package/dist/task/config-types.js +1 -1
- package/dist/task/runtime.js +1 -1
- package/dist/worker/cli.js +216 -0
- package/dist/worker/closeout/apply.js +73 -0
- package/dist/worker/closeout/preview.js +30 -0
- package/dist/worker/delivery/final-verification.js +158 -0
- package/dist/worker/delivery/git-transaction.js +354 -0
- package/dist/worker/delivery/package.js +449 -0
- package/dist/worker/feature/decision-loader.js +68 -0
- package/dist/worker/feature/discover.js +14 -0
- package/dist/worker/feature/next-action.js +74 -0
- package/dist/worker/feature/reducer.js +133 -0
- package/dist/worker/feature/review.js +502 -0
- package/dist/worker/feature/run.js +313 -0
- package/dist/worker/feature/types.js +1 -0
- package/dist/worker/follow-up/approve.js +270 -0
- package/dist/worker/follow-up/factory.js +234 -0
- package/dist/worker/follow-up/paths.js +25 -0
- package/dist/worker/follow-up/policy.js +26 -0
- package/dist/worker/follow-up/schema.js +93 -0
- package/dist/worker/follow-up/store.js +96 -0
- package/dist/worker/loop-agent/loop-agent-client.js +51 -10
- package/dist/worker/metrics/projector.js +139 -0
- package/dist/worker/observability/read-model.js +256 -15
- package/dist/worker/observe/paths.js +17 -5
- package/dist/worker/observe/routes.js +78 -20
- package/dist/worker/observe/server.js +8 -6
- package/dist/worker/observe/static/app.js +1045 -177
- package/dist/worker/observe/static/index.html +70 -43
- package/dist/worker/observe/static/styles.css +553 -610
- package/dist/worker/pool/run-store.js +14 -2
- package/dist/worker/pool/validation.js +59 -0
- package/dist/worker/report/morning-report.js +41 -6
- package/dist/worker/run-task/run-task.js +1 -1
- package/dist/worker/runner/run-ready.js +19 -5
- package/dist/workflows/dag/init-hybrid.js +3 -1
- package/dist/workflows/dag/lifecycle.js +146 -0
- package/dist/workflows/dag/node-execution.js +3 -0
- package/dist/workflows/dag/prompt.js +16 -0
- package/dist/workflows/dag/report.js +2 -0
- package/dist/workflows/dag/runner.js +133 -104
- package/dist/workflows/dag/types.js +3 -0
- package/docs/README.md +21 -0
- package/docs/agent-dag-recovery-playbook.md +1 -1
- package/docs/architecture/runtime-boundaries.md +3 -2
- package/docs/design/README.md +13 -7
- package/docs/exec-plans/active/README.md +2 -2
- package/docs/exec-plans/completed/README.md +15 -0
- package/docs/loop-agent-harness.md +45 -2
- package/docs/progress/README.md +2 -0
- package/docs/reports/README.md +13 -0
- package/docs/templates/agent-dag-report.schema.json +5 -3
- package/docs/templates/harness.schema.json +7 -2
- package/docs/templates/init-evolution-review.md +4 -2
- package/docs/verification-matrix.md +7 -0
- package/harness.json +4 -3
- package/package.json +4 -2
- package/scripts/check-product-line-docs.sh +7 -3
- package/scripts/check-task-pool-root.sh +1 -1
- package/skills/init-capability-evolution/SKILL.md +1 -0
- package/skills/loop-agent/references/command-reference.md +21 -0
- package/skills/loop-agent/references/hybrid-dag.md +4 -3
- package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { access, mkdir, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { findRunByWorkerRunId, getRunsJsonlPath, readJsonlFile } from "../pool/run-store.js";
|
|
5
|
+
import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
|
|
6
|
+
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
7
|
+
import { assertSafeRuntimeId, assertSafeTaskFile, followUpDir } from "./paths.js";
|
|
8
|
+
import { followUpActionCardSchema, followUpDraftSchema, followUpEvidenceSchema } from "./schema.js";
|
|
9
|
+
import { cleanupPath, hashFeaturePacket, readFollowUpIndex, repoRef, resolveRepoFile, sha256File, withFollowUpLock, writeFollowUpIndex, writeJsonAtomic, writeTextAtomic } from "./store.js";
|
|
10
|
+
import { resolveFollowUpPolicy } from "./policy.js";
|
|
11
|
+
export async function draftProductBugFollowUp(input) {
|
|
12
|
+
const result = await draftFollowUpDecision(input, true);
|
|
13
|
+
if (result.kind !== "TaskDraft")
|
|
14
|
+
throw new Error("ProductBug must create a TaskDraft");
|
|
15
|
+
return result;
|
|
16
|
+
}
|
|
17
|
+
export async function draftFollowUpDecision(input, productBugOnly = false) {
|
|
18
|
+
const repoRoot = path.resolve(input.repoRoot);
|
|
19
|
+
const featureDir = path.resolve(input.featureDir);
|
|
20
|
+
assertSafeRuntimeId(input.taskId, "taskId");
|
|
21
|
+
const run = await findRunByWorkerRunId(repoRoot, input.workerRunId);
|
|
22
|
+
if (!run || run.taskId !== input.taskId)
|
|
23
|
+
throw new Error(`failed run not found for ${input.taskId}/${input.workerRunId}`);
|
|
24
|
+
if (run.status === "succeeded")
|
|
25
|
+
throw new Error("cannot draft follow-up from a successful run");
|
|
26
|
+
const category = run.failure?.category;
|
|
27
|
+
if (!category)
|
|
28
|
+
throw new Error("failed run has no failure category");
|
|
29
|
+
if (productBugOnly && category !== "ProductBug")
|
|
30
|
+
throw new Error(`ProductBug draft required, found ${category}`);
|
|
31
|
+
const allRuns = await readJsonlFile(getRunsJsonlPath(repoRoot));
|
|
32
|
+
const policy = resolveFollowUpPolicy({
|
|
33
|
+
category,
|
|
34
|
+
recentTaskRuns: allRuns.filter((candidate) => candidate.featureId === run.featureId && candidate.taskId === input.taskId),
|
|
35
|
+
});
|
|
36
|
+
const graphPath = path.join(featureDir, "tasks", "task-graph.yaml");
|
|
37
|
+
const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(graphPath, "utf-8")));
|
|
38
|
+
assertSafeRuntimeId(graph.feature_id, "featureId");
|
|
39
|
+
assertSafeRuntimeId(run.featureId, "run.featureId");
|
|
40
|
+
if (run.featureId !== graph.feature_id)
|
|
41
|
+
throw new Error(`failed run belongs to ${run.featureId}, not ${graph.feature_id}`);
|
|
42
|
+
return withFollowUpLock(repoRoot, run.featureId, async () => {
|
|
43
|
+
const index = await readFollowUpIndex(repoRoot, run.featureId);
|
|
44
|
+
const dedupeKey = `${run.featureId}:${input.taskId}:${input.workerRunId}:${category}`;
|
|
45
|
+
const existing = index.entries.find((entry) => entry.dedupeKey === dedupeKey);
|
|
46
|
+
if (existing) {
|
|
47
|
+
const safeDraftPath = await resolveRepoFile(repoRoot, existing.draftPath);
|
|
48
|
+
const raw = YAML.parse(await readFile(safeDraftPath, "utf-8"));
|
|
49
|
+
if (existing.kind === "ActionCard") {
|
|
50
|
+
return actionResult(repoRoot, existing.followUpId, path.resolve(repoRoot, existing.draftPath), dedupeKey, followUpActionCardSchema.parse(raw), false);
|
|
51
|
+
}
|
|
52
|
+
if (!existing.proposedTaskId)
|
|
53
|
+
throw new Error("TaskDraft index is missing proposedTaskId");
|
|
54
|
+
return result(repoRoot, existing.followUpId, existing.proposedTaskId, path.resolve(repoRoot, existing.draftPath), dedupeKey, followUpDraftSchema.parse(raw), false);
|
|
55
|
+
}
|
|
56
|
+
if (graph.feature_id !== run.featureId)
|
|
57
|
+
throw new Error("Feature graph id does not match failed run");
|
|
58
|
+
const parentNode = graph.nodes.find((node) => node.id === input.taskId);
|
|
59
|
+
if (!parentNode)
|
|
60
|
+
throw new Error(`parent task is missing from graph: ${input.taskId}`);
|
|
61
|
+
assertSafeTaskFile(parentNode.task, "parent task file");
|
|
62
|
+
for (const node of graph.nodes.filter((candidate) => candidate.depends_on.includes(input.taskId))) {
|
|
63
|
+
assertSafeTaskFile(node.task, `rewire task file for ${node.id}`);
|
|
64
|
+
}
|
|
65
|
+
const sequence = nextSequence(index.entries.filter((entry) => entry.parentTaskId === input.taskId).map((entry) => entry.followUpId));
|
|
66
|
+
const suffix = String(sequence).padStart(2, "0");
|
|
67
|
+
const followUpId = `FU-${input.taskId}-${suffix}`;
|
|
68
|
+
assertSafeRuntimeId(followUpId, "followUpId");
|
|
69
|
+
const evidenceRefs = await Promise.all([
|
|
70
|
+
run.runRecordPath,
|
|
71
|
+
run.dagPath,
|
|
72
|
+
...Object.values(run.failureArtifacts ?? {}),
|
|
73
|
+
].filter((value) => Boolean(value)).map((value) => canonicalEvidenceRef(repoRoot, value)));
|
|
74
|
+
if (evidenceRefs.length === 0)
|
|
75
|
+
throw new Error("failed run has no canonical evidence refs");
|
|
76
|
+
const evidence = followUpEvidenceSchema.parse({
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
followUpId,
|
|
79
|
+
evidence: await Promise.all(evidenceRefs.map(async (ref) => ({ ref, sha256: await sha256File(path.resolve(repoRoot, ref)) }))),
|
|
80
|
+
});
|
|
81
|
+
const directory = followUpDir(repoRoot, run.featureId, followUpId);
|
|
82
|
+
if (await exists(directory))
|
|
83
|
+
throw new Error(`follow-up id collision: ${followUpId}`);
|
|
84
|
+
await mkdir(directory, { recursive: false });
|
|
85
|
+
const draftPath = path.join(directory, "draft.yaml");
|
|
86
|
+
const evidencePath = path.join(directory, "evidence.json");
|
|
87
|
+
try {
|
|
88
|
+
await writeJsonAtomic(evidencePath, evidence);
|
|
89
|
+
const evidenceHash = await sha256File(evidencePath);
|
|
90
|
+
for (const entry of index.entries) {
|
|
91
|
+
if (entry.parentTaskId === input.taskId && (entry.status === "Draft" || entry.status === "ActionRequired"))
|
|
92
|
+
entry.status = "Superseded";
|
|
93
|
+
}
|
|
94
|
+
if (policy.kind === "action-card") {
|
|
95
|
+
const actionCard = followUpActionCardSchema.parse({
|
|
96
|
+
schema_version: 1,
|
|
97
|
+
follow_up_id: followUpId,
|
|
98
|
+
status: "ActionRequired",
|
|
99
|
+
generated_from: { feature_id: run.featureId, task_id: input.taskId, worker_run_id: input.workerRunId, failure_category: category },
|
|
100
|
+
action: policy.action,
|
|
101
|
+
label: policy.label,
|
|
102
|
+
...(policy.recommendedCommand === "task-retry" ? { command: `agent-worker task retry ${JSON.stringify(input.taskId)} --repo ${JSON.stringify(repoRoot)}` } : {}),
|
|
103
|
+
evidence_refs: evidenceRefs,
|
|
104
|
+
dedupe_key: dedupeKey,
|
|
105
|
+
created_at: (input.now ?? new Date()).toISOString(),
|
|
106
|
+
});
|
|
107
|
+
await writeTextAtomic(draftPath, YAML.stringify(actionCard));
|
|
108
|
+
index.entries.push({ kind: "ActionCard", followUpId, parentTaskId: input.taskId, workerRunId: input.workerRunId, dedupeKey, draftHash: await sha256File(draftPath), evidenceHash, status: "ActionRequired", draftPath: repoRef(repoRoot, draftPath), createdAt: actionCard.created_at });
|
|
109
|
+
await writeFollowUpIndex(repoRoot, index);
|
|
110
|
+
return actionResult(repoRoot, followUpId, draftPath, dedupeKey, actionCard, true);
|
|
111
|
+
}
|
|
112
|
+
const proposedTaskId = `${policy.taskIdPrefix}-${input.taskId}-${suffix}`;
|
|
113
|
+
assertSafeRuntimeId(proposedTaskId, "proposedTaskId");
|
|
114
|
+
const parentTaskPath = path.join(featureDir, "tasks", parentNode.task);
|
|
115
|
+
const parentTask = taskSpecSchema.parse(YAML.parse(await readFile(parentTaskPath, "utf-8")));
|
|
116
|
+
const proposedTaskSpec = buildFixTask(parentTask, proposedTaskId, input.workerRunId, category, policy);
|
|
117
|
+
const draft = {
|
|
118
|
+
schema_version: 1,
|
|
119
|
+
follow_up_id: followUpId,
|
|
120
|
+
proposed_task_id: proposedTaskId,
|
|
121
|
+
status: "Draft",
|
|
122
|
+
generated_from: {
|
|
123
|
+
feature_id: run.featureId,
|
|
124
|
+
task_id: input.taskId,
|
|
125
|
+
worker_run_id: input.workerRunId,
|
|
126
|
+
failure_category: category,
|
|
127
|
+
},
|
|
128
|
+
lineage: { parent_task_id: input.taskId, recommended_action: policy.recommendedAction },
|
|
129
|
+
evidence_refs: evidenceRefs,
|
|
130
|
+
evidence_manifest_hash: evidenceHash,
|
|
131
|
+
dedupe_key: dedupeKey,
|
|
132
|
+
feature_packet_hash: await hashFeaturePacket(featureDir),
|
|
133
|
+
proposed_task_spec: proposedTaskSpec,
|
|
134
|
+
proposed_graph_patch: {
|
|
135
|
+
add_node: { id: proposedTaskId, task: `${proposedTaskId}.yaml`, type: policy.taskType, depends_on: [...parentNode.depends_on] },
|
|
136
|
+
rewire_dependents: graph.nodes.filter((node) => node.depends_on.includes(input.taskId)).map((node) => ({ task_id: node.id, from_dependency: input.taskId, to_dependency: proposedTaskId })),
|
|
137
|
+
},
|
|
138
|
+
created_at: (input.now ?? new Date()).toISOString(),
|
|
139
|
+
};
|
|
140
|
+
followUpDraftSchema.parse(draft);
|
|
141
|
+
await writeTextAtomic(draftPath, YAML.stringify(draft));
|
|
142
|
+
const draftHash = await sha256File(draftPath);
|
|
143
|
+
index.entries.push({
|
|
144
|
+
kind: "TaskDraft",
|
|
145
|
+
followUpId,
|
|
146
|
+
proposedTaskId,
|
|
147
|
+
parentTaskId: input.taskId,
|
|
148
|
+
workerRunId: input.workerRunId,
|
|
149
|
+
dedupeKey,
|
|
150
|
+
draftHash,
|
|
151
|
+
evidenceHash,
|
|
152
|
+
status: "Draft",
|
|
153
|
+
draftPath: repoRef(repoRoot, draftPath),
|
|
154
|
+
createdAt: draft.created_at,
|
|
155
|
+
});
|
|
156
|
+
await writeFollowUpIndex(repoRoot, index);
|
|
157
|
+
return result(repoRoot, followUpId, proposedTaskId, draftPath, dedupeKey, draft, true);
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await cleanupPath(directory).catch(() => { });
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
function buildFixTask(parent, taskId, workerRunId, category, policy) {
|
|
166
|
+
const qaScoped = category === "TestBug" || category === "FlakyTest";
|
|
167
|
+
const infraScoped = category === "DependencyFailure" || category === "EnvFailure";
|
|
168
|
+
const productBugFromQa = category === "ProductBug" && parent.type.startsWith("qa-");
|
|
169
|
+
const qaDiscoveredProductPaths = productBugFromQa
|
|
170
|
+
? parent.constraints.forbidden_paths.filter((candidate) => /(^|\/)(src|services|app|apps)(\/|\*|$)/i.test(candidate))
|
|
171
|
+
: [];
|
|
172
|
+
const narrowedAllowedPaths = productBugFromQa
|
|
173
|
+
? qaDiscoveredProductPaths
|
|
174
|
+
: qaScoped
|
|
175
|
+
? parent.constraints.allowed_paths.filter((candidate) => /(^|\/)(test|tests|qa|spec)(\/|\*|$)/i.test(candidate))
|
|
176
|
+
: infraScoped
|
|
177
|
+
? parent.constraints.allowed_paths.filter((candidate) => /package|lock|vendor|depend|config|script|(^|\/)ci(\/|$)|docker|\.github/i.test(candidate))
|
|
178
|
+
: [...parent.constraints.allowed_paths];
|
|
179
|
+
const forbidBusinessCode = qaScoped || infraScoped;
|
|
180
|
+
return taskSpecSchema.parse({
|
|
181
|
+
...parent,
|
|
182
|
+
id: taskId,
|
|
183
|
+
title: `${policy.recommendedAction}: ${parent.id} — ${parent.title}`,
|
|
184
|
+
description: `Follow-up for ${category} from Worker run ${workerRunId}. ${parent.description}`.trim(),
|
|
185
|
+
type: policy.taskType,
|
|
186
|
+
risk_level: infraScoped && narrowedAllowedPaths.length === 0 ? "low" : parent.risk_level,
|
|
187
|
+
depends_on: [...parent.depends_on],
|
|
188
|
+
scope: {
|
|
189
|
+
...parent.scope,
|
|
190
|
+
goals: [`${policy.goalPrefix} from ${parent.id}/${workerRunId}`, ...parent.scope.goals],
|
|
191
|
+
non_goals: productBugFromQa ? parent.scope.non_goals.filter((item) => !/do not modify product code/i.test(item)) : parent.scope.non_goals,
|
|
192
|
+
},
|
|
193
|
+
constraints: {
|
|
194
|
+
...parent.constraints,
|
|
195
|
+
allowed_paths: narrowedAllowedPaths,
|
|
196
|
+
forbidden_paths: productBugFromQa
|
|
197
|
+
? [...new Set([...parent.constraints.allowed_paths, "test/**", "tests/**", "qa/**"])]
|
|
198
|
+
: forbidBusinessCode
|
|
199
|
+
? [...new Set([...parent.constraints.forbidden_paths, "src/**", "services/**", "app/**", "apps/**"])]
|
|
200
|
+
: [...parent.constraints.forbidden_paths],
|
|
201
|
+
hard_constraints: [...parent.constraints.hard_constraints.filter((item) => !productBugFromQa || !/read-only qa execution/i.test(item)), ...(productBugFromQa ? ["Do not weaken or remove the failing regression test"] : []), `Preserve lineage to failed run ${workerRunId}`],
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function nextSequence(ids) {
|
|
206
|
+
return Math.max(0, ...ids.map((id) => Number.parseInt(id.match(/-(\d+)$/)?.[1] ?? "0", 10))) + 1;
|
|
207
|
+
}
|
|
208
|
+
async function canonicalEvidenceRef(repoRoot, value) {
|
|
209
|
+
try {
|
|
210
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
211
|
+
const candidate = await resolveRepoFile(repoRoot, value);
|
|
212
|
+
return path.relative(canonicalRoot, candidate).split(path.sep).join("/");
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
throw new Error(`canonical evidence must be inside the target repo: ${value}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function result(repoRoot, followUpId, proposedTaskId, draftPath, dedupeKey, draft, created) {
|
|
219
|
+
const directory = path.dirname(draftPath);
|
|
220
|
+
return { schemaVersion: 1, kind: "TaskDraft", created, followUpId, proposedTaskId, draftPath, evidencePath: path.join(directory, "evidence.json"), indexPath: path.join(path.dirname(directory), "index.json"), dedupeKey, draft };
|
|
221
|
+
}
|
|
222
|
+
function actionResult(repoRoot, followUpId, draftPath, dedupeKey, actionCard, created) {
|
|
223
|
+
const directory = path.dirname(draftPath);
|
|
224
|
+
return { schemaVersion: 1, kind: "ActionCard", created, followUpId, draftPath, evidencePath: path.join(directory, "evidence.json"), indexPath: path.join(path.dirname(directory), "index.json"), dedupeKey, actionCard };
|
|
225
|
+
}
|
|
226
|
+
async function exists(filePath) {
|
|
227
|
+
try {
|
|
228
|
+
await access(filePath);
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
3
|
+
const SAFE_RUNTIME_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
4
|
+
const SAFE_TASK_FILE = /^[A-Za-z0-9][A-Za-z0-9_-]*\.ya?ml$/;
|
|
5
|
+
export function assertSafeRuntimeId(value, label) {
|
|
6
|
+
if (!SAFE_RUNTIME_ID.test(value)) {
|
|
7
|
+
throw new Error(`${label} contains unsafe path characters: ${JSON.stringify(value)}`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function assertSafeTaskFile(value, label) {
|
|
11
|
+
if (!SAFE_TASK_FILE.test(value) || path.basename(value) !== value) {
|
|
12
|
+
throw new Error(`${label} must be a safe single-file YAML reference: ${JSON.stringify(value)}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function followUpRoot(repoRoot, featureId) {
|
|
16
|
+
assertSafeRuntimeId(featureId, "featureId");
|
|
17
|
+
return path.join(getTaskPoolRoot(repoRoot), "artifacts", "features", featureId, "follow-ups");
|
|
18
|
+
}
|
|
19
|
+
export function followUpIndexPath(repoRoot, featureId) {
|
|
20
|
+
return path.join(followUpRoot(repoRoot, featureId), "index.json");
|
|
21
|
+
}
|
|
22
|
+
export function followUpDir(repoRoot, featureId, followUpId) {
|
|
23
|
+
assertSafeRuntimeId(followUpId, "followUpId");
|
|
24
|
+
return path.join(followUpRoot(repoRoot, featureId), followUpId);
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const TASK_POLICIES = {
|
|
2
|
+
ProductBug: { kind: "task-draft", recommendedAction: "dev-fix", taskIdPrefix: "FIX", taskType: "fix-from-failure", goalPrefix: "Resolve ProductBug" },
|
|
3
|
+
TestBug: { kind: "task-draft", recommendedAction: "qa-fix", taskIdPrefix: "FIX", taskType: "bugfix", goalPrefix: "Repair the failing test contract" },
|
|
4
|
+
FlakyTest: { kind: "task-draft", recommendedAction: "qa-failure-analysis", taskIdPrefix: "FIX", taskType: "qa-failure-analysis", goalPrefix: "Analyse and eliminate flaky verification" },
|
|
5
|
+
DependencyFailure: { kind: "task-draft", recommendedAction: "dependency-fix", taskIdPrefix: "FIX", taskType: "fix-from-failure", goalPrefix: "Restore the blocked dependency" },
|
|
6
|
+
};
|
|
7
|
+
const ACTION_POLICIES = {
|
|
8
|
+
SpecUnclear: { kind: "action-card", action: "clarify-spec", label: "Clarify the specification before creating a new TaskSpec." },
|
|
9
|
+
ContractMismatch: { kind: "action-card", action: "review-contract", label: "Review the architecture or API contract and let a human decide the next task." },
|
|
10
|
+
RiskyChange: { kind: "action-card", action: "review-risk", label: "Complete an architecture and human risk gate before changing scope." },
|
|
11
|
+
NeedsHuman: { kind: "action-card", action: "human-triage", label: "A human decision is required before work can continue." },
|
|
12
|
+
Unknown: { kind: "action-card", action: "human-triage", label: "Triage the unknown failure and classify it before creating work." },
|
|
13
|
+
};
|
|
14
|
+
export function resolveFollowUpPolicy(input) {
|
|
15
|
+
const taskPolicy = TASK_POLICIES[input.category];
|
|
16
|
+
if (taskPolicy)
|
|
17
|
+
return taskPolicy;
|
|
18
|
+
if (input.category === "EnvFailure") {
|
|
19
|
+
const recentFailures = input.recentTaskRuns.slice(-2);
|
|
20
|
+
if (recentFailures.length === 2 && recentFailures.every((run) => run.failure?.category === "EnvFailure")) {
|
|
21
|
+
return { kind: "task-draft", recommendedAction: "env-check", taskIdPrefix: "ENV-CHECK", taskType: "qa-analysis", goalPrefix: "Diagnose and correct repeated environment failure" };
|
|
22
|
+
}
|
|
23
|
+
return { kind: "action-card", action: "retry-task", label: "Correct the environment and retry the unchanged task first.", recommendedCommand: "task-retry" };
|
|
24
|
+
}
|
|
25
|
+
return ACTION_POLICIES[input.category];
|
|
26
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
3
|
+
import { taskGraphNodeSchema } from "../task-graph/task-graph-schema.js";
|
|
4
|
+
const failureCategorySchema = z.enum(["SpecUnclear", "ContractMismatch", "ProductBug", "TestBug", "EnvFailure", "FlakyTest", "RiskyChange", "DependencyFailure", "NeedsHuman", "Unknown"]);
|
|
5
|
+
export const followUpDraftSchema = z.object({
|
|
6
|
+
schema_version: z.literal(1),
|
|
7
|
+
follow_up_id: z.string().min(1),
|
|
8
|
+
proposed_task_id: z.string().min(1),
|
|
9
|
+
status: z.enum(["Draft", "Approved"]),
|
|
10
|
+
generated_from: z.object({
|
|
11
|
+
feature_id: z.string().min(1),
|
|
12
|
+
task_id: z.string().min(1),
|
|
13
|
+
worker_run_id: z.string().min(1),
|
|
14
|
+
dag_run_id: z.string().min(1).optional(),
|
|
15
|
+
failure_category: failureCategorySchema,
|
|
16
|
+
}),
|
|
17
|
+
lineage: z.object({
|
|
18
|
+
parent_task_id: z.string().min(1),
|
|
19
|
+
recommended_action: z.enum(["dev-fix", "qa-fix", "qa-failure-analysis", "dependency-fix", "env-check"]),
|
|
20
|
+
}),
|
|
21
|
+
evidence_refs: z.array(z.string().min(1)).min(1),
|
|
22
|
+
evidence_manifest_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
23
|
+
dedupe_key: z.string().min(1),
|
|
24
|
+
feature_packet_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
25
|
+
proposed_task_spec: taskSpecSchema,
|
|
26
|
+
proposed_graph_patch: z.object({
|
|
27
|
+
add_node: taskGraphNodeSchema,
|
|
28
|
+
rewire_dependents: z.array(z.object({
|
|
29
|
+
task_id: z.string().min(1),
|
|
30
|
+
from_dependency: z.string().min(1),
|
|
31
|
+
to_dependency: z.string().min(1),
|
|
32
|
+
})),
|
|
33
|
+
}),
|
|
34
|
+
created_at: z.string().datetime(),
|
|
35
|
+
}).strict();
|
|
36
|
+
export const followUpActionCardSchema = z.object({
|
|
37
|
+
schema_version: z.literal(1),
|
|
38
|
+
follow_up_id: z.string().min(1),
|
|
39
|
+
status: z.literal("ActionRequired"),
|
|
40
|
+
generated_from: z.object({
|
|
41
|
+
feature_id: z.string().min(1),
|
|
42
|
+
task_id: z.string().min(1),
|
|
43
|
+
worker_run_id: z.string().min(1),
|
|
44
|
+
failure_category: failureCategorySchema,
|
|
45
|
+
}),
|
|
46
|
+
action: z.enum(["retry-task", "clarify-spec", "review-contract", "review-risk", "human-triage"]),
|
|
47
|
+
label: z.string().min(1),
|
|
48
|
+
command: z.string().min(1).optional(),
|
|
49
|
+
evidence_refs: z.array(z.string().min(1)).min(1),
|
|
50
|
+
dedupe_key: z.string().min(1),
|
|
51
|
+
created_at: z.string().datetime(),
|
|
52
|
+
}).strict();
|
|
53
|
+
export const followUpIndexSchema = z.object({
|
|
54
|
+
schemaVersion: z.literal(1),
|
|
55
|
+
featureId: z.string().min(1),
|
|
56
|
+
entries: z.array(z.object({
|
|
57
|
+
kind: z.enum(["TaskDraft", "ActionCard"]),
|
|
58
|
+
followUpId: z.string().min(1),
|
|
59
|
+
proposedTaskId: z.string().min(1).optional(),
|
|
60
|
+
parentTaskId: z.string().min(1),
|
|
61
|
+
workerRunId: z.string().min(1),
|
|
62
|
+
dedupeKey: z.string().min(1),
|
|
63
|
+
draftHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
64
|
+
evidenceHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
65
|
+
status: z.enum(["Draft", "ActionRequired", "Approved", "Superseded", "Rejected"]),
|
|
66
|
+
draftPath: z.string().min(1),
|
|
67
|
+
approvalPath: z.string().min(1).optional(),
|
|
68
|
+
createdAt: z.string().datetime(),
|
|
69
|
+
approvedAt: z.string().datetime().optional(),
|
|
70
|
+
})),
|
|
71
|
+
}).strict();
|
|
72
|
+
export const followUpEvidenceSchema = z.object({
|
|
73
|
+
schemaVersion: z.literal(1),
|
|
74
|
+
followUpId: z.string().min(1),
|
|
75
|
+
evidence: z.array(z.object({
|
|
76
|
+
ref: z.string().min(1),
|
|
77
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
78
|
+
})).min(1),
|
|
79
|
+
}).strict();
|
|
80
|
+
export const followUpApprovalSchema = z.object({
|
|
81
|
+
schemaVersion: z.literal(1),
|
|
82
|
+
followUpId: z.string().min(1),
|
|
83
|
+
proposedTaskId: z.string().min(1),
|
|
84
|
+
owner: z.string().min(1),
|
|
85
|
+
approvedAt: z.string().datetime(),
|
|
86
|
+
draftHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
87
|
+
beforeGraphHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
88
|
+
afterGraphHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
89
|
+
taskSpecPath: z.string().min(1),
|
|
90
|
+
graphPath: z.string().min(1),
|
|
91
|
+
statePath: z.string().min(1),
|
|
92
|
+
eventPath: z.string().min(1),
|
|
93
|
+
}).strict();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, open, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { followUpIndexSchema } from "./schema.js";
|
|
5
|
+
import { followUpIndexPath, followUpRoot } from "./paths.js";
|
|
6
|
+
export async function readFollowUpIndex(repoRoot, featureId) {
|
|
7
|
+
const indexPath = followUpIndexPath(repoRoot, featureId);
|
|
8
|
+
try {
|
|
9
|
+
return followUpIndexSchema.parse(JSON.parse(await readFile(indexPath, "utf-8")));
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (isNotFound(error))
|
|
13
|
+
return { schemaVersion: 1, featureId, entries: [] };
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function writeFollowUpIndex(repoRoot, index) {
|
|
18
|
+
await writeJsonAtomic(followUpIndexPath(repoRoot, index.featureId), index);
|
|
19
|
+
}
|
|
20
|
+
export async function writeJsonAtomic(filePath, value) {
|
|
21
|
+
await writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
22
|
+
}
|
|
23
|
+
export async function writeTextAtomic(filePath, value) {
|
|
24
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
25
|
+
const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${randomUUID()}.tmp`);
|
|
26
|
+
await writeFile(tempPath, value, "utf-8");
|
|
27
|
+
await rename(tempPath, filePath);
|
|
28
|
+
}
|
|
29
|
+
export async function withFollowUpLock(repoRoot, featureId, run) {
|
|
30
|
+
const root = followUpRoot(repoRoot, featureId);
|
|
31
|
+
await mkdir(root, { recursive: true });
|
|
32
|
+
const lockPath = path.join(root, ".lock");
|
|
33
|
+
let handle;
|
|
34
|
+
try {
|
|
35
|
+
handle = await open(lockPath, "wx");
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new Error(`follow-up transaction is already active for ${featureId}`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return await run();
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await handle.close().catch(() => { });
|
|
45
|
+
await unlink(lockPath).catch(() => { });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function hashFeaturePacket(featureDir) {
|
|
49
|
+
const files = await listFiles(featureDir);
|
|
50
|
+
const hash = createHash("sha256");
|
|
51
|
+
for (const filePath of files) {
|
|
52
|
+
const relative = path.relative(featureDir, filePath).split(path.sep).join("/");
|
|
53
|
+
hash.update(relative);
|
|
54
|
+
hash.update("\0");
|
|
55
|
+
hash.update(await readFile(filePath));
|
|
56
|
+
hash.update("\0");
|
|
57
|
+
}
|
|
58
|
+
return hash.digest("hex");
|
|
59
|
+
}
|
|
60
|
+
export async function sha256File(filePath) {
|
|
61
|
+
return createHash("sha256").update(await readFile(filePath)).digest("hex");
|
|
62
|
+
}
|
|
63
|
+
export function repoRef(repoRoot, filePath) {
|
|
64
|
+
const relative = path.relative(repoRoot, filePath);
|
|
65
|
+
return relative.startsWith("..") || path.isAbsolute(relative)
|
|
66
|
+
? filePath
|
|
67
|
+
: relative.split(path.sep).join("/");
|
|
68
|
+
}
|
|
69
|
+
export async function resolveRepoFile(repoRoot, ref) {
|
|
70
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
71
|
+
const candidate = await realpath(path.resolve(repoRoot, ref));
|
|
72
|
+
const relative = path.relative(canonicalRoot, candidate);
|
|
73
|
+
if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
74
|
+
throw new Error(`path must be inside the target repo: ${ref}`);
|
|
75
|
+
}
|
|
76
|
+
return candidate;
|
|
77
|
+
}
|
|
78
|
+
export async function cleanupPath(filePath) {
|
|
79
|
+
await rm(filePath, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
async function listFiles(root) {
|
|
82
|
+
const output = [];
|
|
83
|
+
for (const entry of await readdir(root, { withFileTypes: true })) {
|
|
84
|
+
if (entry.name.startsWith(".followup-stage-"))
|
|
85
|
+
continue;
|
|
86
|
+
const absolute = path.join(root, entry.name);
|
|
87
|
+
if (entry.isDirectory())
|
|
88
|
+
output.push(...await listFiles(absolute));
|
|
89
|
+
if (entry.isFile())
|
|
90
|
+
output.push(absolute);
|
|
91
|
+
}
|
|
92
|
+
return output.sort();
|
|
93
|
+
}
|
|
94
|
+
function isNotFound(error) {
|
|
95
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
96
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { appendFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { parseCommandJson } from "./parse-json.js";
|
|
6
6
|
export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
|
|
7
7
|
export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
8
|
+
const MAX_BUFFERED_OUTPUT_BYTES = 64 * 1024;
|
|
8
9
|
export class LoopAgentClient {
|
|
9
10
|
loopAgentBin;
|
|
10
11
|
baseArgs;
|
|
@@ -35,7 +36,7 @@ export class LoopAgentClient {
|
|
|
35
36
|
const stdoutPath = path.join(artifactDir, "stdout.txt");
|
|
36
37
|
const stderrPath = path.join(artifactDir, "stderr.txt");
|
|
37
38
|
const resultPath = path.join(artifactDir, "result.json");
|
|
38
|
-
const { stdout, stderr, exitCode, timedOut } = await spawnCommand({
|
|
39
|
+
const { stdout, stdoutBytes, stdoutTruncated, stderr, stderrBytes, stderrTruncated, exitCode, timedOut, } = await spawnCommand({
|
|
39
40
|
command,
|
|
40
41
|
args: commandArgs,
|
|
41
42
|
cwd: options.cwd,
|
|
@@ -58,7 +59,11 @@ export class LoopAgentClient {
|
|
|
58
59
|
durationMs: Date.now() - startedAt,
|
|
59
60
|
exitCode,
|
|
60
61
|
stdout,
|
|
62
|
+
stdoutBytes,
|
|
63
|
+
stdoutTruncated,
|
|
61
64
|
stderr,
|
|
65
|
+
stderrBytes,
|
|
66
|
+
stderrTruncated,
|
|
62
67
|
timedOut,
|
|
63
68
|
artifacts: {
|
|
64
69
|
dir: artifactDir,
|
|
@@ -68,7 +73,10 @@ export class LoopAgentClient {
|
|
|
68
73
|
},
|
|
69
74
|
};
|
|
70
75
|
if (options.expectJson) {
|
|
71
|
-
const
|
|
76
|
+
const jsonSource = stdoutTruncated
|
|
77
|
+
? await readFullOutputArtifact(stdoutPath, stdout)
|
|
78
|
+
: stdout;
|
|
79
|
+
const parsed = parseCommandJson(jsonSource);
|
|
72
80
|
if (parsed.ok) {
|
|
73
81
|
result.json = parsed.value;
|
|
74
82
|
}
|
|
@@ -77,8 +85,6 @@ export class LoopAgentClient {
|
|
|
77
85
|
result.parseFailure = parsed.failure;
|
|
78
86
|
}
|
|
79
87
|
}
|
|
80
|
-
await writeFile(result.artifacts.stdoutPath, stdout, "utf-8");
|
|
81
|
-
await writeFile(result.artifacts.stderrPath, stderr, "utf-8");
|
|
82
88
|
await writeFile(result.artifacts.resultPath, `${JSON.stringify(result, null, 2)}\n`, "utf-8");
|
|
83
89
|
return result;
|
|
84
90
|
}
|
|
@@ -109,8 +115,8 @@ function spawnCommand(input) {
|
|
|
109
115
|
resultPath: input.resultPath,
|
|
110
116
|
},
|
|
111
117
|
});
|
|
112
|
-
let stdout =
|
|
113
|
-
let stderr =
|
|
118
|
+
let stdout = createBoundedOutput();
|
|
119
|
+
let stderr = createBoundedOutput();
|
|
114
120
|
let timedOut = false;
|
|
115
121
|
const timeout = setTimeout(() => {
|
|
116
122
|
timedOut = true;
|
|
@@ -132,12 +138,12 @@ function spawnCommand(input) {
|
|
|
132
138
|
child.stdout.setEncoding("utf8");
|
|
133
139
|
child.stderr.setEncoding("utf8");
|
|
134
140
|
child.stdout.on("data", (chunk) => {
|
|
135
|
-
stdout
|
|
141
|
+
stdout = appendBoundedOutput(stdout, chunk);
|
|
136
142
|
appendChunkBestEffort(input.stdoutPath, chunk);
|
|
137
143
|
invokeChunkCallback(input.onStdout, chunk);
|
|
138
144
|
});
|
|
139
145
|
child.stderr.on("data", (chunk) => {
|
|
140
|
-
stderr
|
|
146
|
+
stderr = appendBoundedOutput(stderr, chunk);
|
|
141
147
|
appendChunkBestEffort(input.stderrPath, chunk);
|
|
142
148
|
invokeChunkCallback(input.onStderr, chunk);
|
|
143
149
|
});
|
|
@@ -147,10 +153,45 @@ function spawnCommand(input) {
|
|
|
147
153
|
});
|
|
148
154
|
child.on("close", (exitCode) => {
|
|
149
155
|
clearTimers();
|
|
150
|
-
resolve({
|
|
156
|
+
resolve({
|
|
157
|
+
stdout: formatBoundedOutput(stdout),
|
|
158
|
+
stdoutBytes: stdout.bytes,
|
|
159
|
+
stdoutTruncated: stdout.truncated,
|
|
160
|
+
stderr: formatBoundedOutput(stderr),
|
|
161
|
+
stderrBytes: stderr.bytes,
|
|
162
|
+
stderrTruncated: stderr.truncated,
|
|
163
|
+
exitCode,
|
|
164
|
+
timedOut,
|
|
165
|
+
});
|
|
151
166
|
});
|
|
152
167
|
});
|
|
153
168
|
}
|
|
169
|
+
function createBoundedOutput() {
|
|
170
|
+
return { bytes: 0, text: "", truncated: false };
|
|
171
|
+
}
|
|
172
|
+
function appendBoundedOutput(current, chunk) {
|
|
173
|
+
const bytes = current.bytes + Buffer.byteLength(chunk);
|
|
174
|
+
const combined = current.text + chunk;
|
|
175
|
+
if (Buffer.byteLength(combined) <= MAX_BUFFERED_OUTPUT_BYTES) {
|
|
176
|
+
return { bytes, text: combined, truncated: current.truncated };
|
|
177
|
+
}
|
|
178
|
+
const tail = Buffer.from(combined)
|
|
179
|
+
.subarray(-MAX_BUFFERED_OUTPUT_BYTES)
|
|
180
|
+
.toString("utf-8")
|
|
181
|
+
.replace(/^\uFFFD/, "");
|
|
182
|
+
return { bytes, text: tail, truncated: true };
|
|
183
|
+
}
|
|
184
|
+
function formatBoundedOutput(output) {
|
|
185
|
+
return output.truncated ? `${output.text}\n...[truncated]` : output.text;
|
|
186
|
+
}
|
|
187
|
+
async function readFullOutputArtifact(filePath, fallback) {
|
|
188
|
+
try {
|
|
189
|
+
return await readFile(filePath, "utf-8");
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return fallback;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
154
195
|
function appendChunkBestEffort(filePath, chunk) {
|
|
155
196
|
try {
|
|
156
197
|
appendFileSync(filePath, chunk, "utf-8");
|