@tea-agent/loop-agent 0.18.1 → 0.20.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 -2
- package/CHANGELOG.md +33 -0
- package/README.md +4 -6
- package/dist/application/dag/generate-task-dag.js +12 -1
- package/dist/commands/init.js +3 -3
- package/dist/executors/shell-executor.js +188 -2
- package/dist/governance/exec-plans.js +4 -0
- package/dist/worker/cli.js +13 -12
- package/dist/worker/console/doctor.js +55 -2
- package/dist/worker/console/index.js +1 -0
- package/dist/worker/console/loopback.js +2 -2
- package/dist/worker/console/observe-link.js +7 -2
- package/dist/worker/console/operator-actions.js +30 -2
- package/dist/worker/console/operator-selection.js +92 -0
- package/dist/worker/console/operator-surface-health.js +23 -0
- package/dist/worker/console/recovery-cta.js +2 -2
- package/dist/worker/console/routes.js +45 -19
- package/dist/worker/console/security.js +29 -4
- package/dist/worker/console/server.js +106 -8
- package/dist/worker/console/static/assets/index-3vsjZJHq.js +16 -0
- package/dist/worker/console/static/assets/index-i1wV4LrY.css +1 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/observe/routes.js +63 -21
- package/dist/worker/observe/server.js +3 -10
- package/dist/worker/observe/static/index.html +6 -3
- package/dist/worker/observe/static/styles.css +53 -0
- package/dist/workflows/dag/backend-test-markdown-workflow.js +163 -41
- package/dist/workflows/dag/backend-test-result-contract.js +30 -7
- package/dist/workflows/dag/frontend-prewrite-gate.js +77 -0
- package/dist/workflows/dag/frontend-repair.js +7 -1
- package/dist/workflows/dag/frontend-review-context.js +43 -0
- package/dist/workflows/dag/frontend-verification-trace.js +34 -15
- package/dist/workflows/dag/governance-profile.js +14 -6
- package/dist/workflows/dag/init-hybrid.js +143 -399
- package/dist/workflows/dag/types.js +30 -0
- package/dist/workflows/dag/validate.js +22 -1
- package/docs/README.md +4 -2
- package/docs/architecture/evolution.md +6 -6
- package/docs/architecture/worker-and-feature.md +9 -9
- package/docs/templates/agent-dag.schema.json +40 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +23 -192
- package/docs/templates/backend-test-dag.json +8 -8
- package/docs/templates/backend-test-dag.review-cases.prompt.md +22 -75
- package/package.json +1 -1
- package/skills/agent-worker/references/agent-worker-operator.md +2 -2
- package/skills/frontend-implementation/references/node-contracts.md +6 -8
- package/skills/frontend-review/SKILL.md +5 -8
- package/skills/loop-agent/references/command-reference.md +4 -2
- package/skills/loop-agent/references/harness-policy.md +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/dist/worker/console/static/assets/index-KUSib7aM.js +0 -16
- package/dist/worker/console/static/assets/index-ucIzpaGJ.css +0 -1
|
@@ -178,8 +178,10 @@ export function parseJunitXml(xml) {
|
|
|
178
178
|
if (openedCases !== closedCases + selfClosingCases) {
|
|
179
179
|
throw new Error("invalid junit xml: unclosed testcase element");
|
|
180
180
|
}
|
|
181
|
-
// Prefer root testsuites aggregates when present
|
|
181
|
+
// Prefer root testsuites aggregates when present, then fall back to the first
|
|
182
|
+
// testsuite aggregate. Pytest commonly emits time only on <testsuite>.
|
|
182
183
|
const suitesOpen = trimmed.match(/<testsuites\b[^>]*>/i)?.[0];
|
|
184
|
+
const firstSuiteOpen = trimmed.match(/<testsuite\b[^>]*>/i)?.[0];
|
|
183
185
|
let tests = 0;
|
|
184
186
|
let failed = 0;
|
|
185
187
|
let errors = 0;
|
|
@@ -202,9 +204,15 @@ export function parseJunitXml(xml) {
|
|
|
202
204
|
if (time !== undefined && time !== "")
|
|
203
205
|
timeSec = Number(time);
|
|
204
206
|
}
|
|
207
|
+
if (timeSec === undefined && firstSuiteOpen) {
|
|
208
|
+
const time = attr(firstSuiteOpen, "time");
|
|
209
|
+
if (time !== undefined && time !== "")
|
|
210
|
+
timeSec = Number(time);
|
|
211
|
+
}
|
|
205
212
|
// Self-closing first so empty cases ending with /> are not greedily paired with a later </testcase>.
|
|
206
213
|
const caseRe = /<testcase\b([^>]*?)\/>|<testcase\b([^>]*)>([\s\S]*?)<\/testcase>/gi;
|
|
207
214
|
const failures = [];
|
|
215
|
+
const cases = [];
|
|
208
216
|
let caseCount = 0;
|
|
209
217
|
let caseFailed = 0;
|
|
210
218
|
let caseErrors = 0;
|
|
@@ -216,35 +224,49 @@ export function parseJunitXml(xml) {
|
|
|
216
224
|
const body = match[3] ?? "";
|
|
217
225
|
const classname = attr(openAttrs, "classname") || "unknown";
|
|
218
226
|
const name = attr(openAttrs, "name") || "unknown";
|
|
227
|
+
const caseTime = attr(openAttrs, "time");
|
|
228
|
+
const caseDurationMs = caseTime !== undefined && caseTime !== "" && !Number.isNaN(Number(caseTime))
|
|
229
|
+
? Math.round(Number(caseTime) * 1000)
|
|
230
|
+
: undefined;
|
|
219
231
|
const failureTag = body.match(/<failure\b([^>]*)>([\s\S]*?)<\/failure>|<failure\b([^>]*)\/>/i);
|
|
220
232
|
const errorTag = body.match(/<error\b([^>]*)>([\s\S]*?)<\/error>|<error\b([^>]*)\/>/i);
|
|
221
233
|
const skippedTag = /<skipped\b/i.test(body);
|
|
222
234
|
if (failureTag) {
|
|
223
235
|
caseFailed += 1;
|
|
224
236
|
const fAttrs = failureTag[1] ?? failureTag[3] ?? "";
|
|
225
|
-
const fBody = failureTag[2] ?? "";
|
|
226
|
-
const message = attr(fAttrs, "message") || fBody || "failure";
|
|
237
|
+
const fBody = decodeXmlEntities(failureTag[2] ?? "").trim();
|
|
238
|
+
const message = decodeXmlEntities(attr(fAttrs, "message") || fBody || "failure");
|
|
239
|
+
const summary = truncate(message);
|
|
227
240
|
failures.push({
|
|
228
241
|
classname,
|
|
229
242
|
name,
|
|
230
|
-
message:
|
|
243
|
+
message: summary,
|
|
231
244
|
kind: "failure",
|
|
232
245
|
});
|
|
246
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "failure", message: summary, details: fBody || summary });
|
|
233
247
|
}
|
|
234
248
|
else if (errorTag) {
|
|
235
249
|
caseErrors += 1;
|
|
236
250
|
const eAttrs = errorTag[1] ?? errorTag[3] ?? "";
|
|
237
|
-
const eBody = errorTag[2] ?? "";
|
|
238
|
-
const message = attr(eAttrs, "message") || eBody || "error";
|
|
251
|
+
const eBody = decodeXmlEntities(errorTag[2] ?? "").trim();
|
|
252
|
+
const message = decodeXmlEntities(attr(eAttrs, "message") || eBody || "error");
|
|
253
|
+
const summary = truncate(message);
|
|
239
254
|
failures.push({
|
|
240
255
|
classname,
|
|
241
256
|
name,
|
|
242
|
-
message:
|
|
257
|
+
message: summary,
|
|
243
258
|
kind: "error",
|
|
244
259
|
});
|
|
260
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "error", message: summary, details: eBody || summary });
|
|
245
261
|
}
|
|
246
262
|
else if (skippedTag) {
|
|
247
263
|
caseSkipped += 1;
|
|
264
|
+
const skippedAttrs = body.match(/<skipped\b([^>]*)/i)?.[1] ?? "";
|
|
265
|
+
const message = attr(skippedAttrs, "message");
|
|
266
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "skipped", ...(message ? { message } : {}) });
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
cases.push({ classname, name, durationMs: caseDurationMs, status: "passed" });
|
|
248
270
|
}
|
|
249
271
|
match = caseRe.exec(trimmed);
|
|
250
272
|
}
|
|
@@ -322,6 +344,7 @@ export function parseJunitXml(xml) {
|
|
|
322
344
|
durationMs: timeSec !== undefined && !Number.isNaN(timeSec)
|
|
323
345
|
? Math.round(timeSec * 1000)
|
|
324
346
|
: undefined,
|
|
347
|
+
cases,
|
|
325
348
|
failures: failures.slice(0, MAX_FAILURES),
|
|
326
349
|
};
|
|
327
350
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { frontendImplementationContractSchema, materializeFrontendImplementationContract, } from "./frontend-implementation-contract.js";
|
|
4
|
+
async function selectNode(runDir, primary, fallbacks) {
|
|
5
|
+
for (const nodeId of [primary, ...fallbacks.filter((id) => id !== primary)]) {
|
|
6
|
+
try {
|
|
7
|
+
await stat(path.join(runDir, `${nodeId}.json`));
|
|
8
|
+
return nodeId;
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === "ENOENT")
|
|
12
|
+
continue;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
throw new Error(`frontend prewrite gate missing node output: ${[primary, ...fallbacks].join(", ")}`);
|
|
17
|
+
}
|
|
18
|
+
async function readNodeText(runDir, nodeId) {
|
|
19
|
+
const record = JSON.parse(await readFile(path.join(runDir, `${nodeId}.json`), "utf8"));
|
|
20
|
+
const text = record.assistantText?.trim() || record.stdout?.trim() || "";
|
|
21
|
+
if (!text)
|
|
22
|
+
throw new Error(`frontend prewrite gate empty output from ${nodeId}`);
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
function firstVerdictLine(text) {
|
|
26
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
27
|
+
const line = raw.trim().replace(/^\*{1,3}\s*(VERDICT:[^*]+?)\s*\*{1,3}$/, "$1").trim();
|
|
28
|
+
if (line.startsWith("VERDICT:"))
|
|
29
|
+
return line;
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
export async function runFrontendPrewriteGate(input) {
|
|
34
|
+
const planNodeId = await selectNode(input.runDir, input.config.planFromNodeId, input.config.planFallbackFromNodeIds);
|
|
35
|
+
const reviewNodeId = await selectNode(input.runDir, input.config.reviewFromNodeId, input.config.reviewFallbackFromNodeIds);
|
|
36
|
+
const planText = await readNodeText(input.runDir, planNodeId);
|
|
37
|
+
const reviewText = await readNodeText(input.runDir, reviewNodeId);
|
|
38
|
+
const verdict = firstVerdictLine(reviewText);
|
|
39
|
+
if (verdict !== "VERDICT: pass") {
|
|
40
|
+
throw new Error(`frontend prewrite gate blocked by ${reviewNodeId}: ${verdict || "missing VERDICT"}`);
|
|
41
|
+
}
|
|
42
|
+
const missingIds = input.config.requiredRequirementIds.filter((id) => !planText.includes(id));
|
|
43
|
+
if (missingIds.length > 0) {
|
|
44
|
+
throw new Error(`frontend prewrite gate missing requirement ids: ${missingIds.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
const artifact = await materializeFrontendImplementationContract({
|
|
47
|
+
runDir: input.runDir,
|
|
48
|
+
fromNodeId: planNodeId,
|
|
49
|
+
artifactName: input.config.artifactName,
|
|
50
|
+
outputDir: input.config.outputDir,
|
|
51
|
+
sourceBinding: input.sourceBinding,
|
|
52
|
+
});
|
|
53
|
+
const raw = JSON.parse(await readFile(artifact.path, "utf8"));
|
|
54
|
+
const contract = frontendImplementationContractSchema.parse(raw);
|
|
55
|
+
if (!input.config.allowedMockStrategies.includes(contract.mockApi.strategy)) {
|
|
56
|
+
throw new Error(`frontend prewrite gate blocked mock strategy ${contract.mockApi.strategy}; allowed=${input.config.allowedMockStrategies.join(",")}`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
planNodeId,
|
|
61
|
+
reviewNodeId,
|
|
62
|
+
verdict,
|
|
63
|
+
mockStrategy: contract.mockApi.strategy,
|
|
64
|
+
artifact,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function formatFrontendPrewriteGateStdout(result) {
|
|
68
|
+
return [
|
|
69
|
+
"Frontend prewrite gate: pass",
|
|
70
|
+
`Plan: ${result.planNodeId}`,
|
|
71
|
+
`Review: ${result.reviewNodeId}`,
|
|
72
|
+
`Mock strategy: ${result.mockStrategy}`,
|
|
73
|
+
`Structured artifact: ${result.artifact.path}`,
|
|
74
|
+
`Schema: ${result.artifact.schemaId}`,
|
|
75
|
+
`SHA-256: ${result.artifact.sha256}`,
|
|
76
|
+
].join("\n");
|
|
77
|
+
}
|
|
@@ -190,6 +190,8 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
190
190
|
throw new Error("assess: missing or invalid contracts/frontend-implementation-contract.json");
|
|
191
191
|
}
|
|
192
192
|
const candidateNodeIds = [
|
|
193
|
+
"frontend-verify-assess-shell",
|
|
194
|
+
"frontend-reverify-shell",
|
|
193
195
|
"frontend-static-verify-shell",
|
|
194
196
|
"frontend-behavior-verify-shell",
|
|
195
197
|
"frontend-verification-trace-shell",
|
|
@@ -197,8 +199,12 @@ export async function runFrontendFailureAssessGate(input) {
|
|
|
197
199
|
"frontend-behavior-reverify-shell",
|
|
198
200
|
"frontend-verification-retrace-shell",
|
|
199
201
|
];
|
|
200
|
-
const failed = [
|
|
202
|
+
const failed = [
|
|
203
|
+
...(input.failureFacts ?? []).filter(({ record }) => nodeHadFailure(record) || recordIndicatesCommandFailure(record)),
|
|
204
|
+
];
|
|
201
205
|
for (const nodeId of candidateNodeIds) {
|
|
206
|
+
if (failed.some((item) => item.nodeId === nodeId))
|
|
207
|
+
continue;
|
|
202
208
|
const nodePath = path.join(input.runDir, `${nodeId}.json`);
|
|
203
209
|
try {
|
|
204
210
|
const record = JSON.parse(await readFile(nodePath, "utf8"));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { writeDagRunJsonArtifact } from "../../infrastructure/harness/artifact-store.js";
|
|
4
|
+
import { formatFrontendWorktreeDiffStdout, runFrontendWorktreeDiffGate, } from "./frontend-worktree-diff.js";
|
|
5
|
+
export const FRONTEND_REVIEW_CONTEXT_SCHEMA_ID = "frontend-review-context-v1";
|
|
6
|
+
async function readRequiredJson(runDir, relativePath) {
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(await readFile(path.join(runDir, relativePath), "utf8"));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new Error(`frontend review context missing or invalid ${relativePath}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export async function runFrontendReviewContextGate(input) {
|
|
15
|
+
const diff = await runFrontendWorktreeDiffGate(input);
|
|
16
|
+
const contract = await readRequiredJson(input.runDir, "contracts/frontend-implementation-contract.json");
|
|
17
|
+
const verificationTrace = await readRequiredJson(input.runDir, "contracts/frontend-verification-trace.json");
|
|
18
|
+
const repairAssessment = await readRequiredJson(input.runDir, "contracts/frontend-repair-assessment.json");
|
|
19
|
+
const payload = {
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
schemaId: FRONTEND_REVIEW_CONTEXT_SCHEMA_ID,
|
|
22
|
+
contract,
|
|
23
|
+
verificationTrace,
|
|
24
|
+
repairAssessment,
|
|
25
|
+
diff: {
|
|
26
|
+
schemaId: diff.schemaId,
|
|
27
|
+
patchPath: diff.patchPath,
|
|
28
|
+
patchSha256: diff.patchSha256,
|
|
29
|
+
changedFiles: diff.changedFiles,
|
|
30
|
+
untrackedFiles: diff.untrackedFiles,
|
|
31
|
+
empty: diff.empty,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const artifactPath = await writeDagRunJsonArtifact(input.runDir, "contracts/frontend-review-context.json", payload);
|
|
35
|
+
return { ok: true, artifactPath, diff };
|
|
36
|
+
}
|
|
37
|
+
export function formatFrontendReviewContextStdout(result) {
|
|
38
|
+
return [
|
|
39
|
+
"Frontend review context: pass",
|
|
40
|
+
`Artifact: ${result.artifactPath}`,
|
|
41
|
+
formatFrontendWorktreeDiffStdout(result.diff),
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
@@ -95,26 +95,45 @@ export async function runFrontendVerificationTraceGate(input) {
|
|
|
95
95
|
}
|
|
96
96
|
throw new Error(`trace: missing static/behavior verify node records (tried ${errors.join(", ")})`);
|
|
97
97
|
}
|
|
98
|
-
const staticNode =
|
|
99
|
-
|
|
98
|
+
const staticNode = input.evidence
|
|
99
|
+
? {
|
|
100
|
+
nodeId: input.evidence.static.nodeId,
|
|
101
|
+
record: {
|
|
102
|
+
status: "FINISHED",
|
|
103
|
+
verifyEvidence: { commandLabels: input.evidence.static.commandLabels },
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
: await loadFirstNode(staticCandidates);
|
|
107
|
+
const behaviorNode = input.evidence
|
|
108
|
+
? {
|
|
109
|
+
nodeId: input.evidence.behavior.nodeId,
|
|
110
|
+
record: {
|
|
111
|
+
status: "FINISHED",
|
|
112
|
+
verifyEvidence: { commandLabels: input.evidence.behavior.commandLabels },
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
: await loadFirstNode(behaviorCandidates);
|
|
100
116
|
assertNodeFinished(staticNode.nodeId, staticNode.record);
|
|
101
117
|
assertNodeFinished(behaviorNode.nodeId, behaviorNode.record);
|
|
102
118
|
const labelOwners = collectCommandLabels({ nodeId: staticNode.nodeId, record: staticNode.record }, { nodeId: behaviorNode.nodeId, record: behaviorNode.record });
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
// Legacy runs may still carry a standalone Mock assessment. New slim runs
|
|
120
|
+
// bind the strategy directly through the validated implementation contract.
|
|
121
|
+
if (!input.evidence) {
|
|
122
|
+
const assessPath = path.join(input.runDir, "frontend-mock-assess-pi.json");
|
|
123
|
+
try {
|
|
124
|
+
const assess = (await readJsonFile(assessPath));
|
|
125
|
+
const text = assess.assistantText?.trim() || assess.stdout?.trim() || "";
|
|
126
|
+
const strategy = parseMockStrategyFromAssess(text);
|
|
127
|
+
if (strategy && strategy !== contract.mockApi.strategy) {
|
|
128
|
+
throw new Error(`trace: mock strategy mismatch: assess=${strategy} contract=${contract.mockApi.strategy}`);
|
|
129
|
+
}
|
|
110
130
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof Error &&
|
|
133
|
+
error.message.startsWith("trace: mock strategy mismatch")) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
116
136
|
}
|
|
117
|
-
// assess optional for pure unit fixtures without mock node
|
|
118
137
|
}
|
|
119
138
|
const targets = [];
|
|
120
139
|
const hardIssues = [];
|
|
@@ -104,16 +104,24 @@ function writeSetMatchesAnyPattern(writeSet, patterns) {
|
|
|
104
104
|
return undefined;
|
|
105
105
|
}
|
|
106
106
|
function shellCommandLooksDeterministic(command) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
const normalized = command.replace(/["']/g, "");
|
|
108
|
+
return (/\bnpx vitest run\b/.test(normalized) ||
|
|
109
|
+
/\bnpm (?:run )?(?:lint|typecheck|test)\b/.test(normalized) ||
|
|
110
|
+
/check-repo\.sh/.test(normalized) ||
|
|
111
|
+
/\bshell\.preset\b/.test(normalized) ||
|
|
112
|
+
/loop-agent-standard-verify/.test(normalized));
|
|
112
113
|
}
|
|
113
114
|
function taskHasDeterministicShellVerification(task) {
|
|
114
115
|
if (task.executor !== "shell")
|
|
115
116
|
return false;
|
|
116
|
-
const
|
|
117
|
+
const bundle = task.shell?.frontendVerificationBundle;
|
|
118
|
+
const commands = bundle
|
|
119
|
+
? [
|
|
120
|
+
...bundle.mockCommands,
|
|
121
|
+
...bundle.staticCommands,
|
|
122
|
+
...bundle.behaviorCommands,
|
|
123
|
+
]
|
|
124
|
+
: resolveShellCommands(task.shell ?? { commands: [] });
|
|
117
125
|
return commands.some(shellCommandLooksDeterministic);
|
|
118
126
|
}
|
|
119
127
|
function collectExclusiveWriters(spec) {
|