@tea-agent/loop-agent 0.26.1 → 0.26.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 +47 -0
- package/dist/application/dag/generate-task-dag.js +33 -0
- package/dist/commands/task-source-prepare.js +6 -0
- package/dist/executors/dag-pi-executor.js +156 -9
- package/dist/executors/shell-executor.js +111 -0
- package/dist/executors/shell-presets.js +12 -4
- package/dist/executors/shell-write-guard.js +145 -12
- package/dist/task/config-types.js +6 -0
- package/dist/task/contract/constants.js +1 -0
- package/dist/task/contract/project.js +8 -0
- package/dist/task/contract/schema.js +1 -0
- package/dist/task/frontend-preflight.js +131 -0
- package/dist/task/runtime.js +2 -4
- package/dist/task/source-prepare/build-draft.js +9 -0
- package/dist/task/source-prepare/completeness.js +1 -1
- package/dist/worker/observability/read-model.js +134 -0
- package/dist/worker/observe/static/state.js +61 -0
- package/dist/worker/observe/static/styles.css +8 -0
- package/dist/worker/observe/static/views/dag-graph.js +107 -31
- package/dist/worker/observe/static/views/dag-inspector.js +374 -157
- package/dist/worker/observe/static/views/dag.js +4 -11
- package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
- package/dist/workflows/dag/convergence/controller.js +110 -21
- package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
- package/dist/workflows/dag/frontend-repair.js +29 -29
- package/dist/workflows/dag/frontend-review-context.js +7 -1
- package/dist/workflows/dag/frontend-verification-trace.js +26 -5
- package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
- package/dist/workflows/dag/governance-profile.js +1 -1
- package/dist/workflows/dag/init-hybrid.js +115 -39
- package/dist/workflows/dag/output-protocol.js +180 -7
- package/dist/workflows/dag/runner.js +141 -52
- package/dist/workflows/dag/types.js +5 -1
- package/dist/workflows/dag/validate.js +3 -2
- package/docs/templates/backend-test-dag.json +100 -8
- package/package.json +1 -1
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { constants, createReadStream } from "node:fs";
|
|
4
|
-
import { access, lstat, readlink } from "node:fs/promises";
|
|
4
|
+
import { access, lstat, readlink, unlink } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
7
7
|
async function sha256File(filePath) {
|
|
@@ -58,8 +58,7 @@ export async function snapshotGitStatusPathFingerprints(cwd, status) {
|
|
|
58
58
|
const entries = await Promise.all([...status.keys()].map(async (filePath) => {
|
|
59
59
|
const candidate = path.resolve(root, filePath);
|
|
60
60
|
const relative = path.relative(root, candidate);
|
|
61
|
-
if (relative.startsWith("..") ||
|
|
62
|
-
path.isAbsolute(relative)) {
|
|
61
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
63
62
|
return [filePath, "outside-repository"];
|
|
64
63
|
}
|
|
65
64
|
try {
|
|
@@ -72,10 +71,7 @@ export async function snapshotGitStatusPathFingerprints(cwd, status) {
|
|
|
72
71
|
];
|
|
73
72
|
}
|
|
74
73
|
if (info.isFile()) {
|
|
75
|
-
return [
|
|
76
|
-
filePath,
|
|
77
|
-
`file:${await sha256File(candidate)}`,
|
|
78
|
-
];
|
|
74
|
+
return [filePath, `file:${await sha256File(candidate)}`];
|
|
79
75
|
}
|
|
80
76
|
return [
|
|
81
77
|
filePath,
|
|
@@ -101,7 +97,8 @@ export function isEphemeralToolCachePath(filePath) {
|
|
|
101
97
|
const normalized = normalizePath(filePath);
|
|
102
98
|
if (!normalized)
|
|
103
99
|
return false;
|
|
104
|
-
if (normalized === ".pytest_cache" ||
|
|
100
|
+
if (normalized === ".pytest_cache" ||
|
|
101
|
+
normalized.startsWith(".pytest_cache/")) {
|
|
105
102
|
return true;
|
|
106
103
|
}
|
|
107
104
|
if (normalized === ".mypy_cache" || normalized.startsWith(".mypy_cache/")) {
|
|
@@ -112,7 +109,8 @@ export function isEphemeralToolCachePath(filePath) {
|
|
|
112
109
|
}
|
|
113
110
|
// playwright-cli default session dumps (console/page/network) under repo cwd.
|
|
114
111
|
// Real browser case evidence must still be written under testcase/** explicitly.
|
|
115
|
-
if (normalized === ".playwright-cli" ||
|
|
112
|
+
if (normalized === ".playwright-cli" ||
|
|
113
|
+
normalized.startsWith(".playwright-cli/")) {
|
|
116
114
|
return true;
|
|
117
115
|
}
|
|
118
116
|
if (normalized === ".coverage" || normalized.startsWith(".coverage.")) {
|
|
@@ -164,6 +162,136 @@ export function validateShellWriteGuard(input) {
|
|
|
164
162
|
}
|
|
165
163
|
return { ok: violations.length === 0, violations };
|
|
166
164
|
}
|
|
165
|
+
/** SHA-256 of an empty file, retained for fingerprint tests and diagnostics. */
|
|
166
|
+
export const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
167
|
+
/**
|
|
168
|
+
* A mistaken Git Bash `> nul` / `2> nul` target may contain bounded command
|
|
169
|
+
* output. Keep the recovery cap small enough that a material product artifact
|
|
170
|
+
* cannot be silently discarded while covering ordinary tool diagnostics.
|
|
171
|
+
*/
|
|
172
|
+
export const MAX_BENIGN_ROOT_NUL_BYTES = 1024 * 1024;
|
|
173
|
+
/**
|
|
174
|
+
* Decide whether the exact repository-root `nul` entry is a benign tool
|
|
175
|
+
* artifact that may be removed. Every proof must hold: absent from the before
|
|
176
|
+
* snapshot, brand-new untracked (`??`) in the after snapshot, outside the
|
|
177
|
+
* writer's declared boundary, and currently inspected as a regular file no
|
|
178
|
+
* larger than {@link MAX_BENIGN_ROOT_NUL_BYTES}. Nested keys such as `src/nul`
|
|
179
|
+
* never match the exact root key; missing or unreadable facts stay fail-closed.
|
|
180
|
+
*/
|
|
181
|
+
export function evaluateRootNulCandidate(input) {
|
|
182
|
+
if (!input.afterSnapshot.has("nul")) {
|
|
183
|
+
return { action: "skip", candidatePath: "nul", reason: "nested-path" };
|
|
184
|
+
}
|
|
185
|
+
if (input.beforeSnapshot.has("nul")) {
|
|
186
|
+
return { action: "skip", candidatePath: "nul", reason: "pre-existing" };
|
|
187
|
+
}
|
|
188
|
+
if (input.afterSnapshot.get("nul") !== "??") {
|
|
189
|
+
return {
|
|
190
|
+
action: "skip",
|
|
191
|
+
candidatePath: "nul",
|
|
192
|
+
reason: "not-new-untracked",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (input.candidateAuthorized === true) {
|
|
196
|
+
return {
|
|
197
|
+
action: "skip",
|
|
198
|
+
candidatePath: "nul",
|
|
199
|
+
reason: "authorized-by-write-boundary",
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const fingerprint = input.afterPathFingerprints?.get("nul");
|
|
203
|
+
if (!fingerprint || fingerprint.startsWith("unreadable:")) {
|
|
204
|
+
return {
|
|
205
|
+
action: "skip",
|
|
206
|
+
candidatePath: "nul",
|
|
207
|
+
reason: "unreadable",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (input.candidateIsRegularFile === false ||
|
|
211
|
+
!fingerprint.startsWith("file:")) {
|
|
212
|
+
return {
|
|
213
|
+
action: "skip",
|
|
214
|
+
candidatePath: "nul",
|
|
215
|
+
reason: "not-regular-file",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
const observedSizeBytes = input.candidateSizeBytes ??
|
|
219
|
+
(fingerprint === `file:${EMPTY_FILE_SHA256}` ? 0 : undefined);
|
|
220
|
+
if (observedSizeBytes === undefined ||
|
|
221
|
+
!Number.isSafeInteger(observedSizeBytes) ||
|
|
222
|
+
observedSizeBytes < 0) {
|
|
223
|
+
return { action: "skip", candidatePath: "nul", reason: "unreadable" };
|
|
224
|
+
}
|
|
225
|
+
if (observedSizeBytes > MAX_BENIGN_ROOT_NUL_BYTES) {
|
|
226
|
+
return { action: "skip", candidatePath: "nul", reason: "too-large" };
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
action: "remove",
|
|
230
|
+
candidatePath: "nul",
|
|
231
|
+
reason: "new-untracked-bounded-regular-file",
|
|
232
|
+
observedSizeBytes,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Remove a benign repository-root `nul` artifact with the Node file API only.
|
|
237
|
+
* On win32 libuv addresses the literal name through the `\\?\` NT path form, so
|
|
238
|
+
* a file literally named `nul` can be stat/unlink'ed precisely without shell
|
|
239
|
+
* redirection or `rm`. Non-matching candidates are left untouched; unlink
|
|
240
|
+
* failures surface as a bounded `failed` result so callers stay fail-closed.
|
|
241
|
+
*/
|
|
242
|
+
export async function recoverRootNulArtifact(input) {
|
|
243
|
+
let candidateSizeBytes;
|
|
244
|
+
let candidateIsRegularFile;
|
|
245
|
+
if (input.afterSnapshot.has("nul") &&
|
|
246
|
+
!input.beforeSnapshot.has("nul") &&
|
|
247
|
+
input.afterSnapshot.get("nul") === "??" &&
|
|
248
|
+
input.candidateAuthorized !== true) {
|
|
249
|
+
try {
|
|
250
|
+
const info = await lstat(path.resolve(input.rootCwd, "nul"));
|
|
251
|
+
candidateSizeBytes = info.size;
|
|
252
|
+
candidateIsRegularFile = info.isFile() && !info.isSymbolicLink();
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
candidateIsRegularFile = undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const evaluation = evaluateRootNulCandidate({
|
|
259
|
+
beforeSnapshot: input.beforeSnapshot,
|
|
260
|
+
afterSnapshot: input.afterSnapshot,
|
|
261
|
+
afterPathFingerprints: input.afterPathFingerprints,
|
|
262
|
+
candidateSizeBytes,
|
|
263
|
+
candidateIsRegularFile,
|
|
264
|
+
candidateAuthorized: input.candidateAuthorized,
|
|
265
|
+
});
|
|
266
|
+
if (evaluation.action === "skip") {
|
|
267
|
+
return {
|
|
268
|
+
action: "skipped",
|
|
269
|
+
candidatePath: "nul",
|
|
270
|
+
reason: evaluation.reason,
|
|
271
|
+
...(candidateSizeBytes === undefined ? {} : { observedSizeBytes: candidateSizeBytes }),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const removeFile = input.removeFile ?? ((filePath) => unlink(filePath));
|
|
275
|
+
try {
|
|
276
|
+
await removeFile(path.resolve(input.rootCwd, "nul"));
|
|
277
|
+
return {
|
|
278
|
+
action: "removed",
|
|
279
|
+
candidatePath: "nul",
|
|
280
|
+
reason: evaluation.reason,
|
|
281
|
+
observedSizeBytes: evaluation.observedSizeBytes,
|
|
282
|
+
removedAt: new Date().toISOString(),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
return {
|
|
287
|
+
action: "failed",
|
|
288
|
+
candidatePath: "nul",
|
|
289
|
+
reason: "removal-failed",
|
|
290
|
+
observedSizeBytes: evaluation.observedSizeBytes,
|
|
291
|
+
errorDetail: boundedErrorDetail(error),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
167
295
|
export class GitStatusUnavailableError extends Error {
|
|
168
296
|
diagnostics;
|
|
169
297
|
constructor(input) {
|
|
@@ -179,7 +307,8 @@ export class GitStatusUnavailableError extends Error {
|
|
|
179
307
|
cwd: path.resolve(input.cwd),
|
|
180
308
|
platform: input.platform ?? process.platform,
|
|
181
309
|
executableCandidates: input.executableCandidates ?? [],
|
|
182
|
-
requiredWindowsEnvironment: input.requiredWindowsEnvironment ??
|
|
310
|
+
requiredWindowsEnvironment: input.requiredWindowsEnvironment ??
|
|
311
|
+
requiredWindowsEnvironment(process.env),
|
|
183
312
|
attempts: input.attempts,
|
|
184
313
|
};
|
|
185
314
|
}
|
|
@@ -216,11 +345,15 @@ function formatWindowsExitCode(exitCode) {
|
|
|
216
345
|
return `0x${(exitCode >>> 0).toString(16).padStart(8, "0").toUpperCase()}`;
|
|
217
346
|
}
|
|
218
347
|
function isWindowsDllInitializationFailure(platform, exitCode) {
|
|
219
|
-
return platform === "win32" &&
|
|
348
|
+
return (platform === "win32" &&
|
|
349
|
+
exitCode !== undefined &&
|
|
350
|
+
exitCode >>> 0 === 0xc0000142);
|
|
220
351
|
}
|
|
221
352
|
function boundedErrorDetail(error) {
|
|
222
353
|
const detail = error instanceof Error ? error.message : String(error);
|
|
223
|
-
return detail.length <= 1000
|
|
354
|
+
return detail.length <= 1000
|
|
355
|
+
? detail
|
|
356
|
+
: `${detail.slice(0, 1000)}...[truncated]`;
|
|
224
357
|
}
|
|
225
358
|
export function deriveSameInstallationGitCandidates(primary) {
|
|
226
359
|
const normalized = path.win32.normalize(primary);
|
|
@@ -104,6 +104,12 @@ const taskConfigObjectSchema = z.object({
|
|
|
104
104
|
referenceMaxFilesPerRepo: z.number().int().positive().optional(),
|
|
105
105
|
referenceMaxTotalFiles: z.number().int().positive().optional(),
|
|
106
106
|
allowedPaths: z.array(z.string()).optional().default([]),
|
|
107
|
+
/**
|
|
108
|
+
* Coarse repository roots used only by the frontend preflight scout. Before
|
|
109
|
+
* DAG generation, the scout must resolve these into the narrower
|
|
110
|
+
* `allowedPaths` consumed by writer nodes.
|
|
111
|
+
*/
|
|
112
|
+
allowedRoots: z.array(z.string()).optional(),
|
|
107
113
|
forbiddenPaths: z.array(z.string()).optional().default([]),
|
|
108
114
|
hardConstraints: z.array(z.string()).optional().default([]),
|
|
109
115
|
autoCommitAfterVerify: z.boolean().optional().default(true),
|
|
@@ -107,6 +107,11 @@ export function projectConstraintsMarkdown(draft) {
|
|
|
107
107
|
lines.push(`- ${item}`);
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
if (draft.constraints.allowedRoots?.length) {
|
|
111
|
+
lines.push("", "## Allowed roots (preflight only)", "");
|
|
112
|
+
for (const item of draft.constraints.allowedRoots)
|
|
113
|
+
lines.push(`- ${item}`);
|
|
114
|
+
}
|
|
110
115
|
lines.push("", "## Expected verification", "");
|
|
111
116
|
if (draft.verification.commands.length === 0) {
|
|
112
117
|
lines.push("- (none)");
|
|
@@ -148,6 +153,9 @@ export function mergeManagedTaskConfigFields(existing, draft) {
|
|
|
148
153
|
title: draft.title,
|
|
149
154
|
taskKind: draft.taskKind,
|
|
150
155
|
allowedPaths: canonicalizePathArray(draft.constraints.allowedPaths),
|
|
156
|
+
...(draft.constraints.allowedRoots !== undefined
|
|
157
|
+
? { allowedRoots: canonicalizePathArray(draft.constraints.allowedRoots) }
|
|
158
|
+
: {}),
|
|
151
159
|
forbiddenPaths: canonicalizePathArray(draft.constraints.forbiddenPaths),
|
|
152
160
|
hardConstraints,
|
|
153
161
|
verifyCommands: draft.verification.commands.map((cmd) => ({
|
|
@@ -23,6 +23,7 @@ export const taskContractDraftConstraintsSchema = z
|
|
|
23
23
|
.object({
|
|
24
24
|
invariants: z.array(z.string()),
|
|
25
25
|
allowedPaths: z.array(z.string()),
|
|
26
|
+
allowedRoots: z.array(z.string()).optional(),
|
|
26
27
|
forbiddenPaths: z.array(z.string()),
|
|
27
28
|
protectedUserChanges: z.array(z.string()).optional(),
|
|
28
29
|
expectedVerification: z.array(z.string()).optional(),
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { executePiStep } from "../executors/pi-executor.js";
|
|
5
|
+
import { resolveDagPiModelConfig } from "../executors/dag-pi-executor.js";
|
|
6
|
+
import { writeJsonAtomic } from "../infrastructure/harness/atomic-write.js";
|
|
7
|
+
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
8
|
+
import { loadHarnessManifest } from "../governance/harness.js";
|
|
9
|
+
import { resolveExecutorModelMatrices } from "../executors/model-routing.js";
|
|
10
|
+
const frontendScopeSchema = z.object({
|
|
11
|
+
schemaVersion: z.literal(1),
|
|
12
|
+
entrypoint: z.string().min(1),
|
|
13
|
+
implementationPaths: z.array(z.string().min(1)).min(1),
|
|
14
|
+
testPaths: z.array(z.string().min(1)).min(1),
|
|
15
|
+
dataSource: z.string().min(1),
|
|
16
|
+
});
|
|
17
|
+
function isSafeRepositoryPath(value) {
|
|
18
|
+
return value.length > 0 &&
|
|
19
|
+
!path.isAbsolute(value) &&
|
|
20
|
+
!value.includes("\\") &&
|
|
21
|
+
!value.split("/").includes("..");
|
|
22
|
+
}
|
|
23
|
+
function parseScope(text) {
|
|
24
|
+
const candidates = [];
|
|
25
|
+
for (let start = text.indexOf("{"); start >= 0; start = text.indexOf("{", start + 1)) {
|
|
26
|
+
let depth = 0;
|
|
27
|
+
let quoted = false;
|
|
28
|
+
let escaped = false;
|
|
29
|
+
for (let index = start; index < text.length; index += 1) {
|
|
30
|
+
const char = text[index];
|
|
31
|
+
if (quoted) {
|
|
32
|
+
if (escaped)
|
|
33
|
+
escaped = false;
|
|
34
|
+
else if (char === "\\")
|
|
35
|
+
escaped = true;
|
|
36
|
+
else if (char === '"')
|
|
37
|
+
quoted = false;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (char === '"')
|
|
41
|
+
quoted = true;
|
|
42
|
+
else if (char === "{")
|
|
43
|
+
depth += 1;
|
|
44
|
+
else if (char === "}" && --depth === 0) {
|
|
45
|
+
try {
|
|
46
|
+
candidates.push(JSON.parse(text.slice(start, index + 1)));
|
|
47
|
+
}
|
|
48
|
+
catch { /* continue */ }
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (candidates.length === 0)
|
|
54
|
+
throw new Error("frontend preflight output contains no JSON object");
|
|
55
|
+
const parsed = frontendScopeSchema.parse(candidates.at(-1));
|
|
56
|
+
for (const candidate of [
|
|
57
|
+
parsed.entrypoint,
|
|
58
|
+
...parsed.implementationPaths,
|
|
59
|
+
...parsed.testPaths,
|
|
60
|
+
]) {
|
|
61
|
+
if (!isSafeRepositoryPath(candidate)) {
|
|
62
|
+
throw new Error(`unsafe frontend preflight path: ${candidate}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
function ensureWithinRoots(scope, roots) {
|
|
68
|
+
const candidates = [
|
|
69
|
+
scope.entrypoint,
|
|
70
|
+
...scope.implementationPaths,
|
|
71
|
+
...scope.testPaths,
|
|
72
|
+
];
|
|
73
|
+
const outside = candidates.filter((candidate) => !roots.some((root) => pathMatchesPattern(candidate, root)));
|
|
74
|
+
if (outside.length > 0) {
|
|
75
|
+
throw new Error(`frontend preflight discovered paths outside allowedRoots: ${outside.join(", ")}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function runFrontendPreflight(input) {
|
|
79
|
+
if (input.taskConfig.taskKind !== "frontend-implementation" ||
|
|
80
|
+
(input.taskConfig.allowedRoots?.length ?? 0) === 0) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const requirementPath = path.join(input.repoRoot, ".harness", "tasks", input.taskId, "source", "需求.md");
|
|
84
|
+
const requirement = await readFile(requirementPath, "utf8");
|
|
85
|
+
const manifest = await loadHarnessManifest(input.repoRoot);
|
|
86
|
+
const models = resolveExecutorModelMatrices(manifest);
|
|
87
|
+
const model = models.pi.MED;
|
|
88
|
+
const prompt = [
|
|
89
|
+
"You are a read-only frontend preflight scout. Inspect the target repository before its implementation DAG is generated.",
|
|
90
|
+
"Locate the existing UI surface that satisfies the requirement. Do not invent a new page when an existing surface exists.",
|
|
91
|
+
`Allowed roots: ${input.taskConfig.allowedRoots.join(", ")}`,
|
|
92
|
+
"Return exactly one JSON object and no Markdown or prose:",
|
|
93
|
+
'{"schemaVersion":1,"entrypoint":"relative/path","implementationPaths":["relative/glob-or-file"],"testPaths":["relative/glob-or-file"],"dataSource":"existing store/API/module"}',
|
|
94
|
+
"Every path must be repository-relative POSIX, must be inside allowed roots, and implementationPaths/testPaths must be the narrowest directories or files that own the existing behavior.",
|
|
95
|
+
"Requirement:",
|
|
96
|
+
requirement,
|
|
97
|
+
].join("\n\n");
|
|
98
|
+
const result = await executePiStep({
|
|
99
|
+
attachedFiles: [requirementPath],
|
|
100
|
+
modelConfig: resolveDagPiModelConfig(model),
|
|
101
|
+
prompt,
|
|
102
|
+
repoRoot: input.repoRoot,
|
|
103
|
+
step: "analyze",
|
|
104
|
+
toolNames: ["read", "grep", "find", "ls"],
|
|
105
|
+
userMessage: "Perform the frontend preflight scout and return the required JSON.",
|
|
106
|
+
validateOutput: (assistantText) => {
|
|
107
|
+
try {
|
|
108
|
+
const scope = parseScope(assistantText);
|
|
109
|
+
ensureWithinRoots(scope, input.taskConfig.allowedRoots);
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return [error instanceof Error ? error.message : String(error)];
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
if (!result.ok) {
|
|
118
|
+
throw new Error(`frontend preflight failed: ${result.failureCategory}: ${result.stderr || result.assistantText}`);
|
|
119
|
+
}
|
|
120
|
+
const scope = parseScope(result.assistantText);
|
|
121
|
+
ensureWithinRoots(scope, input.taskConfig.allowedRoots);
|
|
122
|
+
const preflightDir = path.join(input.repoRoot, ".harness", "tasks", input.taskId, "preflight");
|
|
123
|
+
await mkdir(preflightDir, { recursive: true });
|
|
124
|
+
await writeJsonAtomic(path.join(preflightDir, "frontend-scope.json"), {
|
|
125
|
+
...scope,
|
|
126
|
+
resolvedAt: new Date().toISOString(),
|
|
127
|
+
allowedRoots: input.taskConfig.allowedRoots,
|
|
128
|
+
model,
|
|
129
|
+
});
|
|
130
|
+
return scope;
|
|
131
|
+
}
|
package/dist/task/runtime.js
CHANGED
|
@@ -201,10 +201,8 @@ export function parseTaskConfigForRuntime(rawConfig) {
|
|
|
201
201
|
}
|
|
202
202
|
return parsed;
|
|
203
203
|
}
|
|
204
|
-
export function deriveMaxFixLoops(
|
|
205
|
-
|
|
206
|
-
return 2;
|
|
207
|
-
return 3;
|
|
204
|
+
export function deriveMaxFixLoops(_complexity) {
|
|
205
|
+
return 2;
|
|
208
206
|
}
|
|
209
207
|
export async function listTaskSourceFiles(repoRoot, taskId) {
|
|
210
208
|
return listFilesRecursive(getTaskPaths(repoRoot, taskId).sourceDir);
|
|
@@ -65,6 +65,13 @@ function resolveAllowedPaths(input) {
|
|
|
65
65
|
return n.ok ? n.value : raw.trim();
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
|
+
function resolveAllowedRoots(input) {
|
|
69
|
+
const roots = input.flags.allowedRoots ?? input.baseDraft?.constraints.allowedRoots ?? input.existing?.allowedRoots;
|
|
70
|
+
return roots === undefined ? undefined : (cleanStringList(roots) ?? []).map((raw) => {
|
|
71
|
+
const normalized = normalizePreparePath(raw);
|
|
72
|
+
return normalized.ok ? normalized.value : raw.trim();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
68
75
|
function resolveVerifyCommands(input) {
|
|
69
76
|
if (input.flags.verifyCommands !== undefined) {
|
|
70
77
|
return input.flags.verifyCommands.map((cmd) => ({
|
|
@@ -122,6 +129,7 @@ export function buildPrepareDraft(input) {
|
|
|
122
129
|
baseDraft,
|
|
123
130
|
existing: existingTaskConfig,
|
|
124
131
|
});
|
|
132
|
+
const allowedRoots = resolveAllowedRoots({ flags, baseDraft, existing: existingTaskConfig });
|
|
125
133
|
// D12: protected defaults always merge unless explicitly disabled.
|
|
126
134
|
// Explicit --forbidden-path appends; does not replace defaults.
|
|
127
135
|
const finalForbidden = mergeForbiddenPaths({
|
|
@@ -175,6 +183,7 @@ export function buildPrepareDraft(input) {
|
|
|
175
183
|
forbiddenPaths: pathAssessment.forbidden.length > 0
|
|
176
184
|
? pathAssessment.forbidden
|
|
177
185
|
: dedupeStable(finalForbidden),
|
|
186
|
+
...(allowedRoots !== undefined ? { allowedRoots: dedupeStable(allowedRoots) } : {}),
|
|
178
187
|
},
|
|
179
188
|
verification: {
|
|
180
189
|
commands: verifyCommands
|
|
@@ -81,7 +81,7 @@ export function listPrepareGaps(input) {
|
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
83
|
}
|
|
84
|
-
if (draft.constraints.allowedPaths.length === 0) {
|
|
84
|
+
if (draft.constraints.allowedPaths.length === 0 && !(draft.taskKind === "frontend-implementation" && (draft.constraints.allowedRoots?.length ?? 0) > 0)) {
|
|
85
85
|
gaps.push({
|
|
86
86
|
code: "EMPTY_ALLOWED_PATHS",
|
|
87
87
|
level: "blocking",
|
|
@@ -1287,6 +1287,12 @@ function mergeDagRun(existing, incoming) {
|
|
|
1287
1287
|
...(ranks && ranks.length > 0 ? { ranks } : {}),
|
|
1288
1288
|
nodes: mergeDagNodes(existing.nodes, incoming.nodes),
|
|
1289
1289
|
edges: incoming.edges.length > 0 ? incoming.edges : existing.edges,
|
|
1290
|
+
...((incoming.controlEdges ?? existing.controlEdges)
|
|
1291
|
+
? { controlEdges: incoming.controlEdges ?? existing.controlEdges }
|
|
1292
|
+
: {}),
|
|
1293
|
+
...((incoming.convergence ?? existing.convergence)
|
|
1294
|
+
? { convergence: incoming.convergence ?? existing.convergence }
|
|
1295
|
+
: {}),
|
|
1290
1296
|
...((incoming.dagPath ?? existing.dagPath)
|
|
1291
1297
|
? { dagPath: incoming.dagPath ?? existing.dagPath }
|
|
1292
1298
|
: {}),
|
|
@@ -1321,6 +1327,10 @@ function mergeDagNode(existing, incoming) {
|
|
|
1321
1327
|
finishedAt: incoming.finishedAt ?? existing.finishedAt,
|
|
1322
1328
|
outputPreview: incoming.outputPreview ?? existing.outputPreview,
|
|
1323
1329
|
errorPreview: incoming.errorPreview ?? existing.errorPreview,
|
|
1330
|
+
attemptCount: incoming.attemptCount ?? existing.attemptCount,
|
|
1331
|
+
failureCategory: incoming.failureCategory ?? existing.failureCategory,
|
|
1332
|
+
originKind: incoming.originKind ?? existing.originKind,
|
|
1333
|
+
passes: incoming.passes ?? existing.passes,
|
|
1324
1334
|
};
|
|
1325
1335
|
}
|
|
1326
1336
|
function computeDurationMs(startedAt, finishedAt) {
|
|
@@ -1569,6 +1579,9 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1569
1579
|
const nodes = parseDagNodes(parsed, rankByNode, modelByNode);
|
|
1570
1580
|
const edges = await parseDagEdges(runSpecPath, nodes);
|
|
1571
1581
|
const state = parsed;
|
|
1582
|
+
const controlEdges = await deriveControlLoopEdges(runSpecPath, nodes, state);
|
|
1583
|
+
const convergence = projectConvergenceSummary(state);
|
|
1584
|
+
applyNodePasses(nodes, state);
|
|
1572
1585
|
const liveness = assessDagRunLiveness({ state, now });
|
|
1573
1586
|
const effectiveStatus = lifecycle
|
|
1574
1587
|
? deriveDagRunEffectiveStatus({
|
|
@@ -1647,6 +1660,8 @@ async function parseDagStateFile(statePath, now) {
|
|
|
1647
1660
|
...(ranks.length > 0 ? { ranks } : {}),
|
|
1648
1661
|
nodes,
|
|
1649
1662
|
edges,
|
|
1663
|
+
...(controlEdges.length > 0 ? { controlEdges } : {}),
|
|
1664
|
+
...(convergence ? { convergence } : {}),
|
|
1650
1665
|
dagPath: runDir,
|
|
1651
1666
|
...(continuation ? { continuation } : {}),
|
|
1652
1667
|
...(backendTest ? { backendTest } : {}),
|
|
@@ -1683,6 +1698,125 @@ async function parseDagEdges(runPath, nodes) {
|
|
|
1683
1698
|
}
|
|
1684
1699
|
return [...edges.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
1685
1700
|
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Derive presentation-only control-loop edges from the run spec's convergence
|
|
1703
|
+
* chain. When the chain contains both `review-gate-shell` and `repair-pi`, the
|
|
1704
|
+
* bounded repair-reverify-review loop exists at runtime and Observe renders it
|
|
1705
|
+
* as a dashed feedback loop. The derived edge is never merged into `edges` or
|
|
1706
|
+
* `ranks`, so DAG topological layout stays acyclic.
|
|
1707
|
+
*/
|
|
1708
|
+
async function deriveControlLoopEdges(runPath, nodes, state) {
|
|
1709
|
+
const runtimeConvergence = state.convergence;
|
|
1710
|
+
if (!runtimeConvergence?.enabled ||
|
|
1711
|
+
runtimeConvergence.terminalReason === "feature-flag-off" ||
|
|
1712
|
+
runtimeConvergence.terminalReason === "unsupported-dag-shape") {
|
|
1713
|
+
return [];
|
|
1714
|
+
}
|
|
1715
|
+
const parsed = await safeReadJson(runPath);
|
|
1716
|
+
if (!parsed)
|
|
1717
|
+
return [];
|
|
1718
|
+
const convergence = readObject(parsed, "convergence");
|
|
1719
|
+
if (!convergence)
|
|
1720
|
+
return [];
|
|
1721
|
+
const chainNodeIds = readStringArray(convergence, "chainNodeIds");
|
|
1722
|
+
if (chainNodeIds.length === 0)
|
|
1723
|
+
return [];
|
|
1724
|
+
if (!chainNodeIds.includes("review-gate-shell") ||
|
|
1725
|
+
!chainNodeIds.includes("repair-pi")) {
|
|
1726
|
+
return [];
|
|
1727
|
+
}
|
|
1728
|
+
const known = new Set(nodes.map((node) => node.nodeId));
|
|
1729
|
+
if (!known.has("review-gate-shell") || !known.has("repair-pi")) {
|
|
1730
|
+
return [];
|
|
1731
|
+
}
|
|
1732
|
+
return [{ from: "review-gate-shell", to: "repair-pi", kind: "control-loop" }];
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Project `state.convergence` into the slim Observe summary. Keeps the budget
|
|
1736
|
+
* envelope even when passHistory is empty.
|
|
1737
|
+
*/
|
|
1738
|
+
function projectConvergenceSummary(state) {
|
|
1739
|
+
const convergence = state.convergence;
|
|
1740
|
+
if (!convergence)
|
|
1741
|
+
return undefined;
|
|
1742
|
+
return {
|
|
1743
|
+
enabled: convergence.enabled,
|
|
1744
|
+
maxPasses: convergence.maxPasses,
|
|
1745
|
+
currentPass: convergence.currentPass,
|
|
1746
|
+
...(convergence.terminalReason
|
|
1747
|
+
? { terminalReason: convergence.terminalReason }
|
|
1748
|
+
: {}),
|
|
1749
|
+
passHistory: convergence.passHistory.map((pass) => ({
|
|
1750
|
+
pass: pass.pass,
|
|
1751
|
+
...(pass.reason ? { reason: pass.reason } : {}),
|
|
1752
|
+
...(pass.status ? { status: pass.status } : {}),
|
|
1753
|
+
...(pass.hardVerifyStatus
|
|
1754
|
+
? { hardVerifyStatus: pass.hardVerifyStatus }
|
|
1755
|
+
: {}),
|
|
1756
|
+
...(pass.reviewVerdict ? { reviewVerdict: pass.reviewVerdict } : {}),
|
|
1757
|
+
...(pass.reviewGateStatus
|
|
1758
|
+
? { reviewGateStatus: pass.reviewGateStatus }
|
|
1759
|
+
: {}),
|
|
1760
|
+
})),
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Record convergence participation from archived pass artifacts plus the live
|
|
1765
|
+
* current pass. The controller archives only retry/terminal failures, so an
|
|
1766
|
+
* active pass or a successful final pass must be derived from current node
|
|
1767
|
+
* state to avoid dropping its pass marker from Observe.
|
|
1768
|
+
*/
|
|
1769
|
+
function applyNodePasses(nodes, state) {
|
|
1770
|
+
const passesByNode = new Map();
|
|
1771
|
+
const addPass = (nodeId, pass) => {
|
|
1772
|
+
const passes = passesByNode.get(nodeId) ?? new Set();
|
|
1773
|
+
passes.add(pass);
|
|
1774
|
+
passesByNode.set(nodeId, passes);
|
|
1775
|
+
};
|
|
1776
|
+
for (const pass of state.convergence?.passHistory ?? []) {
|
|
1777
|
+
for (const ref of pass.artifactRefs)
|
|
1778
|
+
addPass(ref.nodeId, pass.pass);
|
|
1779
|
+
}
|
|
1780
|
+
const initialPassNodeIds = new Set([
|
|
1781
|
+
"initial-review-pi",
|
|
1782
|
+
"initial-review-gate-shell",
|
|
1783
|
+
"initial-review-pass-shell",
|
|
1784
|
+
]);
|
|
1785
|
+
for (const node of nodes) {
|
|
1786
|
+
if (!initialPassNodeIds.has(node.nodeId))
|
|
1787
|
+
continue;
|
|
1788
|
+
const status = (node.status ?? "").toUpperCase();
|
|
1789
|
+
if (["RUNNING", "FINISHED", "ERROR"].includes(status)) {
|
|
1790
|
+
addPass(node.nodeId, 1);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
const currentPass = state.convergence?.currentPass;
|
|
1794
|
+
if (currentPass && currentPass > 0) {
|
|
1795
|
+
const convergenceNodeIds = new Set([
|
|
1796
|
+
"process-supervisor-pi",
|
|
1797
|
+
"process-gate-shell",
|
|
1798
|
+
"repair-pi",
|
|
1799
|
+
"hard-verify-shell",
|
|
1800
|
+
"review-pi",
|
|
1801
|
+
"review-verdict-recovery-pi",
|
|
1802
|
+
"review-gate-shell",
|
|
1803
|
+
]);
|
|
1804
|
+
for (const node of nodes) {
|
|
1805
|
+
if (!convergenceNodeIds.has(node.nodeId))
|
|
1806
|
+
continue;
|
|
1807
|
+
const status = (node.status ?? "").toUpperCase();
|
|
1808
|
+
if (["RUNNING", "FINISHED", "ERROR"].includes(status)) {
|
|
1809
|
+
addPass(node.nodeId, currentPass);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
for (const node of nodes) {
|
|
1814
|
+
const passes = passesByNode.get(node.nodeId);
|
|
1815
|
+
if (passes && passes.size > 0) {
|
|
1816
|
+
node.passes = [...passes].sort((left, right) => left - right);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1686
1820
|
function buildRankIndex(ranks) {
|
|
1687
1821
|
const index = new Map();
|
|
1688
1822
|
for (let i = 0; i < ranks.length; i++) {
|