@tea-agent/loop-agent 0.16.1 → 0.16.3
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/CHANGELOG.md +27 -0
- package/dist/executors/dag-pi-executor.js +4 -2
- package/dist/executors/pi-sdk-executor.js +66 -3
- package/dist/executors/shell-executor.js +239 -29
- package/dist/executors/shell-presets.js +12 -2
- package/dist/executors/shell-write-guard.js +20 -1
- package/dist/shared/git-progress.js +9 -2
- package/dist/worker/observability/read-model.js +56 -0
- package/dist/worker/observe/server.js +6 -3
- package/dist/workflows/dag/backend-test-analysis-contract.js +87 -30
- package/dist/workflows/dag/backend-test-case-manifest.js +71 -8
- package/dist/workflows/dag/backend-test-execution-contract.js +63 -11
- package/dist/workflows/dag/backend-test-repair-contract.js +94 -0
- package/dist/workflows/dag/backend-test-result-contract.js +6 -4
- package/dist/workflows/dag/backend-test-semantic-review-contract.js +36 -0
- package/dist/workflows/dag/dynamic-runtime/condition.js +1 -1
- package/dist/workflows/dag/dynamic-runtime/shared.js +42 -0
- package/dist/workflows/dag/failure-routing.js +1 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +32 -16
- package/dist/workflows/dag/frontend-worktree-diff.js +127 -0
- package/dist/workflows/dag/init-hybrid.js +616 -120
- package/dist/workflows/dag/lifecycle.js +33 -2
- package/dist/workflows/dag/scheduler.js +87 -17
- package/dist/workflows/dag/types.js +31 -0
- package/dist/workflows/dag/validate.js +20 -14
- package/docs/templates/agent-dag.schema.json +25 -2
- package/docs/templates/backend-test-analysis.schema.json +9 -16
- package/docs/templates/backend-test-dag.json +493 -197
- package/docs/templates/backend-test-dag.review-cases.prompt.md +10 -4
- package/docs/templates/backend-test-execution.schema.json +6 -1
- package/package.json +1 -1
- package/skills/frontend-review/SKILL.md +6 -2
- package/skills/loop-agent/references/hybrid-dag.md +4 -1
|
@@ -186,6 +186,26 @@ export function extractFrontendImplementationJson(text) {
|
|
|
186
186
|
throw new Error("output must contain exactly one fenced json object");
|
|
187
187
|
return JSON.parse(blocks[0][1]);
|
|
188
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Build the authoritative frontend-implementation-contract sourceBinding from
|
|
191
|
+
* the DAG-owned binding. Model JSON must not invent taskId/path/sha256; the
|
|
192
|
+
* gate overwrites whatever the model emitted so identity cannot drift.
|
|
193
|
+
*/
|
|
194
|
+
export function canonicalFrontendContractSourceBinding(binding) {
|
|
195
|
+
const requirement = binding.sources.find((source) => source.kind === "requirement");
|
|
196
|
+
if (!requirement)
|
|
197
|
+
throw new Error("frontend implementation contract gate requires a requirement source in DAG sourceBinding");
|
|
198
|
+
return {
|
|
199
|
+
taskId: binding.taskId,
|
|
200
|
+
requirementPath: requirement.path,
|
|
201
|
+
requirementSha256: requirement.sha256,
|
|
202
|
+
referencePaths: binding.sources
|
|
203
|
+
.filter((source) => source.kind === "reference")
|
|
204
|
+
.map((source) => source.path)
|
|
205
|
+
.sort(),
|
|
206
|
+
requirementIds: [...binding.requirementIds],
|
|
207
|
+
};
|
|
208
|
+
}
|
|
189
209
|
export async function materializeFrontendImplementationContract(input) {
|
|
190
210
|
if (!input.sourceBinding)
|
|
191
211
|
throw new Error("frontend implementation contract gate requires DAG sourceBinding");
|
|
@@ -200,23 +220,19 @@ export async function materializeFrontendImplementationContract(input) {
|
|
|
200
220
|
const secrets = secretIssues(parsed);
|
|
201
221
|
if (secrets.length)
|
|
202
222
|
throw new Error(`invalid-output: ${secrets.join("; ")}`);
|
|
203
|
-
|
|
223
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
224
|
+
throw new Error("invalid-output: frontend contract must be a JSON object");
|
|
225
|
+
const canonicalBinding = canonicalFrontendContractSourceBinding(input.sourceBinding);
|
|
226
|
+
// Always inject DAG-owned identity. Model-provided sourceBinding is advisory
|
|
227
|
+
// only and must not fail a otherwise-valid contract (common live failure:
|
|
228
|
+
// wrong requirementPath/sha, extra referencePaths, or omitted binding).
|
|
229
|
+
const withCanonicalBinding = {
|
|
230
|
+
...parsed,
|
|
231
|
+
sourceBinding: canonicalBinding,
|
|
232
|
+
};
|
|
233
|
+
const result = frontendImplementationContractSchema.safeParse(withCanonicalBinding);
|
|
204
234
|
if (!result.success)
|
|
205
235
|
throw new Error(`invalid-output: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
|
|
206
|
-
const requirement = input.sourceBinding.sources.find((source) => source.kind === "requirement");
|
|
207
|
-
const refs = input.sourceBinding.sources
|
|
208
|
-
.filter((source) => source.kind === "reference")
|
|
209
|
-
.map((source) => source.path)
|
|
210
|
-
.sort();
|
|
211
|
-
const bound = result.data.sourceBinding;
|
|
212
|
-
if (!requirement ||
|
|
213
|
-
bound.taskId !== input.sourceBinding.taskId ||
|
|
214
|
-
bound.requirementPath !== requirement.path ||
|
|
215
|
-
bound.requirementSha256 !== requirement.sha256 ||
|
|
216
|
-
JSON.stringify([...bound.referencePaths].sort()) !== JSON.stringify(refs) ||
|
|
217
|
-
JSON.stringify(bound.requirementIds) !==
|
|
218
|
-
JSON.stringify(input.sourceBinding.requirementIds))
|
|
219
|
-
throw new Error("frontend contract source binding does not match DAG source binding");
|
|
220
236
|
const blockingGaps = [
|
|
221
237
|
...result.data.evidenceGaps,
|
|
222
238
|
...result.data.requirements.flatMap((item) => item.evidenceGap ? [item.evidenceGap] : []),
|
|
@@ -225,7 +241,7 @@ export async function materializeFrontendImplementationContract(input) {
|
|
|
225
241
|
throw new Error(`frontend contract has blocking evidence gap: ${blockingGaps
|
|
226
242
|
.map((item) => item.requirementId ?? item.description)
|
|
227
243
|
.join(", ")}`);
|
|
228
|
-
for (const requirementId of
|
|
244
|
+
for (const requirementId of canonicalBinding.requirementIds)
|
|
229
245
|
if (!result.data.requirements.some((item) => item.id === requirementId) &&
|
|
230
246
|
!result.data.evidenceGaps.some((item) => item.requirementId === requirementId))
|
|
231
247
|
throw new Error(`frontend contract does not cover ${requirementId}`);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
6
|
+
export const FRONTEND_WORKTREE_DIFF_SCHEMA_ID = "frontend-worktree-diff-v1";
|
|
7
|
+
function runGit(cwd, args) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn("git", args, {
|
|
10
|
+
cwd,
|
|
11
|
+
env: process.env,
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
let stdout = "";
|
|
15
|
+
let stderr = "";
|
|
16
|
+
child.stdout.on("data", (chunk) => {
|
|
17
|
+
stdout += String(chunk);
|
|
18
|
+
});
|
|
19
|
+
child.stderr.on("data", (chunk) => {
|
|
20
|
+
stderr += String(chunk);
|
|
21
|
+
});
|
|
22
|
+
child.on("error", reject);
|
|
23
|
+
child.on("close", (code) => {
|
|
24
|
+
resolve({ code: code ?? 1, stdout, stderr });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
function splitLines(text) {
|
|
29
|
+
return text
|
|
30
|
+
.split(/\r?\n/)
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Capture the current workspace worktree as a run-owned review artifact.
|
|
36
|
+
*
|
|
37
|
+
* Frontend review is contractually required to inspect an "actual diff". The
|
|
38
|
+
* model cannot invent one, and failure-path `changes.patch` under Task Pool is
|
|
39
|
+
* not visible while the DAG is still active. This gate freezes a deterministic
|
|
40
|
+
* patch + inventory under the current run directory before review runs.
|
|
41
|
+
*/
|
|
42
|
+
export async function runFrontendWorktreeDiffGate(input) {
|
|
43
|
+
const root = path.resolve(input.workspaceRoot);
|
|
44
|
+
const revParse = await runGit(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
45
|
+
if (revParse.code !== 0 || revParse.stdout.trim() !== "true") {
|
|
46
|
+
throw new Error(`frontend worktree diff gate requires a git worktree: ${revParse.stderr.trim() || revParse.stdout.trim() || "not a git repository"}`);
|
|
47
|
+
}
|
|
48
|
+
// Match failure capture semantics: binary-capable tracked diff vs HEAD.
|
|
49
|
+
const diff = await runGit(root, ["diff", "--binary", "HEAD"]);
|
|
50
|
+
if (diff.code !== 0) {
|
|
51
|
+
throw new Error(`frontend worktree diff gate git diff failed: ${diff.stderr.trim() || diff.stdout.trim()}`);
|
|
52
|
+
}
|
|
53
|
+
const nameOnly = await runGit(root, [
|
|
54
|
+
"diff",
|
|
55
|
+
"--name-only",
|
|
56
|
+
"--diff-filter=ACDMRTUXB",
|
|
57
|
+
"HEAD",
|
|
58
|
+
]);
|
|
59
|
+
if (nameOnly.code !== 0) {
|
|
60
|
+
throw new Error(`frontend worktree diff gate git name-only failed: ${nameOnly.stderr.trim() || nameOnly.stdout.trim()}`);
|
|
61
|
+
}
|
|
62
|
+
const untracked = await runGit(root, [
|
|
63
|
+
"ls-files",
|
|
64
|
+
"--others",
|
|
65
|
+
"--exclude-standard",
|
|
66
|
+
]);
|
|
67
|
+
if (untracked.code !== 0) {
|
|
68
|
+
throw new Error(`frontend worktree diff gate ls-files failed: ${untracked.stderr.trim() || untracked.stdout.trim()}`);
|
|
69
|
+
}
|
|
70
|
+
const changedFiles = splitLines(nameOnly.stdout).sort();
|
|
71
|
+
const untrackedFiles = splitLines(untracked.stdout).sort();
|
|
72
|
+
const patchBody = diff.stdout.endsWith("\n") || diff.stdout.length === 0
|
|
73
|
+
? diff.stdout
|
|
74
|
+
: `${diff.stdout}\n`;
|
|
75
|
+
const untrackedSection = untrackedFiles.length === 0
|
|
76
|
+
? ""
|
|
77
|
+
: [
|
|
78
|
+
"",
|
|
79
|
+
"# Untracked files (inventory only; contents not inlined)",
|
|
80
|
+
...untrackedFiles.map((file) => `# untracked: ${file}`),
|
|
81
|
+
"",
|
|
82
|
+
].join("\n");
|
|
83
|
+
const patchText = `${patchBody}${untrackedSection}`;
|
|
84
|
+
const patchSha256 = createHash("sha256").update(patchText).digest("hex");
|
|
85
|
+
const relativePatch = "artifacts/diff_patch.patch";
|
|
86
|
+
const absolutePatch = path.join(input.runDir, ...relativePatch.split("/"));
|
|
87
|
+
await mkdir(path.dirname(absolutePatch), { recursive: true });
|
|
88
|
+
await writeFile(absolutePatch, patchText, "utf8");
|
|
89
|
+
const payload = {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
schemaId: FRONTEND_WORKTREE_DIFF_SCHEMA_ID,
|
|
92
|
+
patchPath: relativePatch,
|
|
93
|
+
patchSha256,
|
|
94
|
+
changedFiles,
|
|
95
|
+
untrackedFiles,
|
|
96
|
+
empty: changedFiles.length === 0 && untrackedFiles.length === 0,
|
|
97
|
+
capturedAt: new Date().toISOString(),
|
|
98
|
+
};
|
|
99
|
+
const artifactPath = await writeDagRunJsonArtifact(input.runDir, "contracts/frontend-worktree-diff.json", payload);
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
schemaId: FRONTEND_WORKTREE_DIFF_SCHEMA_ID,
|
|
103
|
+
patchPath: relativePatch,
|
|
104
|
+
patchAbsolutePath: absolutePatch,
|
|
105
|
+
patchSha256,
|
|
106
|
+
changedFiles,
|
|
107
|
+
untrackedFiles,
|
|
108
|
+
empty: payload.empty,
|
|
109
|
+
capturedAt: payload.capturedAt,
|
|
110
|
+
artifactPath,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function formatFrontendWorktreeDiffStdout(result) {
|
|
114
|
+
return [
|
|
115
|
+
`schemaId=${result.schemaId}`,
|
|
116
|
+
`patchPath=${result.patchPath}`,
|
|
117
|
+
`patchSha256=${result.patchSha256}`,
|
|
118
|
+
`changedFiles=${result.changedFiles.length}`,
|
|
119
|
+
`untrackedFiles=${result.untrackedFiles.length}`,
|
|
120
|
+
`empty=${result.empty ? "true" : "false"}`,
|
|
121
|
+
result.artifactPath ? `artifactPath=${result.artifactPath}` : "",
|
|
122
|
+
...result.changedFiles.map((file) => `changed:${file}`),
|
|
123
|
+
...result.untrackedFiles.map((file) => `untracked:${file}`),
|
|
124
|
+
]
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.join("\n");
|
|
127
|
+
}
|