@tea-agent/loop-agent 0.26.2 → 0.26.4
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 +43 -0
- package/dist/executors/dag-pi-executor.js +182 -10
- package/dist/executors/pi-executor.js +233 -147
- package/dist/executors/pi-sdk-executor.js +140 -81
- package/dist/executors/pi-writer-tool-policy.js +266 -0
- package/dist/executors/shell-executor.js +355 -52
- package/dist/executors/shell-write-guard.js +145 -12
- package/dist/governance/document-index-closure.js +164 -0
- package/dist/worker/observe/node-input.js +8 -11
- package/dist/workflows/dag/convergence/controller.js +100 -3
- package/dist/workflows/dag/frontend-repair.js +29 -29
- package/dist/workflows/dag/frontend-verification-trace.js +12 -2
- package/dist/workflows/dag/governance-profile.js +1 -1
- package/dist/workflows/dag/init-hybrid.js +70 -16
- package/dist/workflows/dag/repair-artifact.js +100 -4
- package/dist/workflows/dag/rerun-plan.js +14 -7
- package/dist/workflows/dag/types.js +7 -1
- package/dist/workflows/dag/workspace-checkpoint.js +42 -0
- package/docs/templates/init-managed-agents.md +3 -3
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +13 -6
|
@@ -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);
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
4
|
+
function normalizePath(value) {
|
|
5
|
+
return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
6
|
+
}
|
|
7
|
+
function isConcreteRepoRelativePath(value) {
|
|
8
|
+
const normalized = normalizePath(value);
|
|
9
|
+
if (!normalized)
|
|
10
|
+
return false;
|
|
11
|
+
if (normalized === "." ||
|
|
12
|
+
normalized === "**" ||
|
|
13
|
+
normalized === "*" ||
|
|
14
|
+
normalized === "/*") {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (normalized.includes("**") || normalized.includes("*"))
|
|
18
|
+
return false;
|
|
19
|
+
if (path.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized))
|
|
20
|
+
return false;
|
|
21
|
+
if (normalized.split("/").some((segment) => segment === ".."))
|
|
22
|
+
return false;
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
function assertValidDirectoryIndexRule(rule, indexLabel) {
|
|
26
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
|
|
27
|
+
throw new Error(`document-index-closure: invalid completeDirectoryIndexes entry at ${indexLabel}`);
|
|
28
|
+
}
|
|
29
|
+
const record = rule;
|
|
30
|
+
if (typeof record.directory !== "string" ||
|
|
31
|
+
record.directory.trim().length === 0) {
|
|
32
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].directory must be a non-empty string`);
|
|
33
|
+
}
|
|
34
|
+
if (typeof record.index !== "string" || record.index.trim().length === 0) {
|
|
35
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].index must be a non-empty string`);
|
|
36
|
+
}
|
|
37
|
+
const directory = normalizePath(record.directory);
|
|
38
|
+
const index = normalizePath(record.index);
|
|
39
|
+
if (!isConcreteRepoRelativePath(directory)) {
|
|
40
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].directory must be a concrete repo-relative path (got "${record.directory}")`);
|
|
41
|
+
}
|
|
42
|
+
if (!isConcreteRepoRelativePath(index)) {
|
|
43
|
+
throw new Error(`document-index-closure: completeDirectoryIndexes[${indexLabel}].index must be a concrete repo-relative path without globs (got "${record.index}")`);
|
|
44
|
+
}
|
|
45
|
+
return { directory, index };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Load completeDirectoryIndexes from docs/document-catalog.json only.
|
|
49
|
+
* Does not hard-code a second directory table.
|
|
50
|
+
*/
|
|
51
|
+
export function loadCompleteDirectoryIndexes(repoRoot) {
|
|
52
|
+
const catalogPath = path.join(repoRoot, "docs", "document-catalog.json");
|
|
53
|
+
// The catalog is an opt-in governance surface. Projects that have not
|
|
54
|
+
// initialized it must still be able to generate DAGs for docs paths.
|
|
55
|
+
if (!existsSync(catalogPath))
|
|
56
|
+
return [];
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = readFileSync(catalogPath, "utf-8");
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
throw new Error(`document-index-closure: failed to read docs/document-catalog.json: ${error instanceof Error ? error.message : String(error)}`);
|
|
63
|
+
}
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(raw);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
throw new Error(`document-index-closure: docs/document-catalog.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
72
|
+
throw new Error("document-index-closure: docs/document-catalog.json root must be an object");
|
|
73
|
+
}
|
|
74
|
+
const rulesRaw = parsed
|
|
75
|
+
.completeDirectoryIndexes;
|
|
76
|
+
if (rulesRaw === undefined) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(rulesRaw)) {
|
|
80
|
+
throw new Error("document-index-closure: completeDirectoryIndexes must be an array when present");
|
|
81
|
+
}
|
|
82
|
+
return rulesRaw.map((rule, index) => assertValidDirectoryIndexRule(rule, String(index)));
|
|
83
|
+
}
|
|
84
|
+
function candidateDirectoryPrefixes(candidatePath) {
|
|
85
|
+
const normalized = normalizePath(candidatePath);
|
|
86
|
+
if (!normalized)
|
|
87
|
+
return [];
|
|
88
|
+
// Treat trailing /** globs as directory roots for prefix matching.
|
|
89
|
+
const withoutGlob = normalized
|
|
90
|
+
.replace(/\/\*\*$/, "")
|
|
91
|
+
.replace(/\/\*$/, "")
|
|
92
|
+
.replace(/\*\*$/, "")
|
|
93
|
+
.replace(/\*$/, "");
|
|
94
|
+
const base = normalizePath(withoutGlob);
|
|
95
|
+
if (!base || base === "." || base === "**")
|
|
96
|
+
return [];
|
|
97
|
+
const segments = base.split("/").filter(Boolean);
|
|
98
|
+
const prefixes = [];
|
|
99
|
+
for (let i = segments.length; i >= 1; i -= 1) {
|
|
100
|
+
prefixes.push(segments.slice(0, i).join("/"));
|
|
101
|
+
}
|
|
102
|
+
return prefixes;
|
|
103
|
+
}
|
|
104
|
+
function longestMatchingRule(candidatePath, rules) {
|
|
105
|
+
const prefixes = new Set(candidateDirectoryPrefixes(candidatePath));
|
|
106
|
+
if (prefixes.size === 0)
|
|
107
|
+
return undefined;
|
|
108
|
+
let best;
|
|
109
|
+
for (const rule of rules) {
|
|
110
|
+
if (!prefixes.has(rule.directory) &&
|
|
111
|
+
!candidateUnderDirectory(candidatePath, rule.directory)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!best || rule.directory.length > best.directory.length) {
|
|
115
|
+
best = rule;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
119
|
+
}
|
|
120
|
+
function candidateUnderDirectory(candidatePath, directory) {
|
|
121
|
+
const normalized = normalizePath(candidatePath)
|
|
122
|
+
.replace(/\/\*\*$/, "")
|
|
123
|
+
.replace(/\/\*$/, "");
|
|
124
|
+
const dir = normalizePath(directory);
|
|
125
|
+
return normalized === dir || normalized.startsWith(`${dir}/`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Derive concrete companion index paths for candidate docs paths using catalog rules.
|
|
129
|
+
* Longest directory prefix wins. Throws fail-closed on forbidden conflicts.
|
|
130
|
+
*/
|
|
131
|
+
export function resolveDocumentIndexCompanions(input) {
|
|
132
|
+
const forbidden = input.forbiddenPaths ?? [];
|
|
133
|
+
const companions = new Set();
|
|
134
|
+
for (const candidate of input.candidatePaths) {
|
|
135
|
+
const rule = longestMatchingRule(candidate, input.rules);
|
|
136
|
+
if (!rule)
|
|
137
|
+
continue;
|
|
138
|
+
const indexPath = rule.index;
|
|
139
|
+
if (forbidden.some((pattern) => pathMatchesPattern(indexPath, pattern))) {
|
|
140
|
+
throw new Error(`document-index-closure: companion index "${indexPath}" for "${candidate}" conflicts with forbiddenPaths`);
|
|
141
|
+
}
|
|
142
|
+
companions.add(indexPath);
|
|
143
|
+
}
|
|
144
|
+
return [...companions].sort((a, b) => a.localeCompare(b));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Convenience: load catalog and merge companion indexes into path lists.
|
|
148
|
+
*/
|
|
149
|
+
export function mergeDocumentIndexCompanions(input) {
|
|
150
|
+
const rules = loadCompleteDirectoryIndexes(input.repoRoot);
|
|
151
|
+
const companions = resolveDocumentIndexCompanions({
|
|
152
|
+
candidatePaths: input.paths,
|
|
153
|
+
rules,
|
|
154
|
+
forbiddenPaths: input.forbiddenPaths,
|
|
155
|
+
});
|
|
156
|
+
const merged = new Set(input.paths.map(normalizePath));
|
|
157
|
+
for (const companion of companions) {
|
|
158
|
+
merged.add(companion);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
paths: [...merged],
|
|
162
|
+
companions,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { redactSecrets, truncateUtf8Preview
|
|
3
|
+
import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
|
|
4
4
|
import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
|
|
5
5
|
import { resolveDagNodePromptRedacted, resolveDagRunJson, } from "./dag-run-artifacts.js";
|
|
6
6
|
export const NODE_INPUT_SCHEMA_VERSION = 1;
|
|
@@ -142,9 +142,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
142
142
|
? spec.defaults
|
|
143
143
|
: undefined;
|
|
144
144
|
const executorDeclared = typeof task.executor === "string";
|
|
145
|
-
const executor = executorDeclared
|
|
146
|
-
? String(task.executor)
|
|
147
|
-
: "pi";
|
|
145
|
+
const executor = executorDeclared ? String(task.executor) : "pi";
|
|
148
146
|
const executorSource = executorDeclared
|
|
149
147
|
? "task"
|
|
150
148
|
: "schema-default";
|
|
@@ -190,7 +188,9 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
190
188
|
const shellRaw = task.shell && typeof task.shell === "object" && !Array.isArray(task.shell)
|
|
191
189
|
? task.shell
|
|
192
190
|
: null;
|
|
193
|
-
const staticRaw = task.static &&
|
|
191
|
+
const staticRaw = task.static &&
|
|
192
|
+
typeof task.static === "object" &&
|
|
193
|
+
!Array.isArray(task.static)
|
|
194
194
|
? task.static
|
|
195
195
|
: null;
|
|
196
196
|
let kind = "unknown";
|
|
@@ -206,9 +206,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
206
206
|
preset: typeof shellRaw.preset === "string"
|
|
207
207
|
? redactSecrets(shellRaw.preset)
|
|
208
208
|
: null,
|
|
209
|
-
cwd: typeof shellRaw.cwd === "string"
|
|
210
|
-
? redactSecrets(shellRaw.cwd)
|
|
211
|
-
: null,
|
|
209
|
+
cwd: typeof shellRaw.cwd === "string" ? redactSecrets(shellRaw.cwd) : null,
|
|
212
210
|
timeoutMs: typeof shellRaw.timeoutMs === "number" &&
|
|
213
211
|
Number.isFinite(shellRaw.timeoutMs)
|
|
214
212
|
? shellRaw.timeoutMs
|
|
@@ -216,6 +214,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
216
214
|
nonZeroExitPolicy: typeof shellRaw.nonZeroExitPolicy === "string"
|
|
217
215
|
? shellRaw.nonZeroExitPolicy
|
|
218
216
|
: null,
|
|
217
|
+
failFast: typeof shellRaw.failFast === "boolean" ? shellRaw.failFast : null,
|
|
219
218
|
gates: shellGateNames(shellRaw),
|
|
220
219
|
}
|
|
221
220
|
: null;
|
|
@@ -245,9 +244,7 @@ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warni
|
|
|
245
244
|
executor: redactString(executor),
|
|
246
245
|
executorSource,
|
|
247
246
|
role: typeof task.role === "string" ? redactSecrets(task.role) : null,
|
|
248
|
-
complexity: typeof task.complexity === "string"
|
|
249
|
-
? task.complexity
|
|
250
|
-
: null,
|
|
247
|
+
complexity: typeof task.complexity === "string" ? task.complexity : null,
|
|
251
248
|
writePolicy: writePolicy ? redactSecrets(writePolicy) : null,
|
|
252
249
|
writePolicySource,
|
|
253
250
|
toolProfile: typeof task.toolProfile === "string"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, appendFile, cp, mkdir } from "node:fs/promises";
|
|
1
|
+
import { access, appendFile, cp, mkdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseProcessVerdict } from "../node-execution.js";
|
|
4
4
|
import { freshNodeRecord } from "../dynamic-runtime/shared.js";
|
|
@@ -120,7 +120,8 @@ export async function runConvergencePassController(input) {
|
|
|
120
120
|
isLegitimateReviewRequestRevision(input.state)) {
|
|
121
121
|
return handleReviewRequestRevision(input, reviewGate);
|
|
122
122
|
}
|
|
123
|
-
if (reviewGate.status === "FINISHED"
|
|
123
|
+
if (reviewGate.status === "FINISHED" &&
|
|
124
|
+
isLegitimateReviewPass(input.state)) {
|
|
124
125
|
convergence.terminalReason = "review-pass";
|
|
125
126
|
await appendConvergenceKnowledgePattern({
|
|
126
127
|
cwd: input.cwd,
|
|
@@ -128,6 +129,9 @@ export async function runConvergencePassController(input) {
|
|
|
128
129
|
});
|
|
129
130
|
return { retry: false };
|
|
130
131
|
}
|
|
132
|
+
if (reviewGate.status === "FINISHED") {
|
|
133
|
+
return handleReviewRequestRevision(input, reviewGate);
|
|
134
|
+
}
|
|
131
135
|
// Compatibility: gates without routingAccept still ERROR on revision.
|
|
132
136
|
if (reviewGate.status === "ERROR") {
|
|
133
137
|
return handleReviewRequestRevision(input, reviewGate);
|
|
@@ -151,10 +155,14 @@ async function resolveNoRepairTerminal(input) {
|
|
|
151
155
|
isLegitimateReviewRequestRevision(input.state)) {
|
|
152
156
|
return handleReviewRequestRevision(input, reviewGate);
|
|
153
157
|
}
|
|
154
|
-
if (reviewGate.status === "FINISHED"
|
|
158
|
+
if (reviewGate.status === "FINISHED" &&
|
|
159
|
+
isLegitimateReviewPass(input.state)) {
|
|
155
160
|
convergence.terminalReason = "review-pass";
|
|
156
161
|
return { retry: false };
|
|
157
162
|
}
|
|
163
|
+
if (reviewGate.status === "FINISHED") {
|
|
164
|
+
return handleReviewRequestRevision(input, reviewGate);
|
|
165
|
+
}
|
|
158
166
|
if (reviewGate.status === "ERROR") {
|
|
159
167
|
return handleReviewRequestRevision(input, reviewGate);
|
|
160
168
|
}
|
|
@@ -178,10 +186,61 @@ async function resolveNoRepairTerminal(input) {
|
|
|
178
186
|
* Hard verification failed. Apply the existing non-retry / regression /
|
|
179
187
|
* max-passes guards, then reset the convergence chain for another pass.
|
|
180
188
|
*/
|
|
189
|
+
function repairDispositionOf(artifact) {
|
|
190
|
+
return artifact?.disposition ?? "repairable";
|
|
191
|
+
}
|
|
192
|
+
function hardVerifyFailureFingerprint(input) {
|
|
193
|
+
return [
|
|
194
|
+
input.failureCategory ?? "unknown",
|
|
195
|
+
input.verifyPhase ?? "",
|
|
196
|
+
input.verifyQuota ?? "",
|
|
197
|
+
String(input.verifyCommandCount ?? ""),
|
|
198
|
+
(input.verifyCommandLabels ?? []).join("\n"),
|
|
199
|
+
(input.stderrExcerpt ?? "").slice(0, 500),
|
|
200
|
+
].join("|");
|
|
201
|
+
}
|
|
202
|
+
function fingerprintFromHardVerify(hardVerify) {
|
|
203
|
+
const verifyEvidence = hardVerify.verifyEvidence;
|
|
204
|
+
// Keep fingerprint fields aligned with passHistory so consecutive passes
|
|
205
|
+
// compare the same identity (category + verifyEvidence command contract).
|
|
206
|
+
// stderr is intentionally excluded: pass history does not retain it, and
|
|
207
|
+
// empty repair manifests already prove no workspace progress.
|
|
208
|
+
return hardVerifyFailureFingerprint({
|
|
209
|
+
failureCategory: hardVerify.failureCategory,
|
|
210
|
+
verifyPhase: verifyEvidence?.phase,
|
|
211
|
+
verifyQuota: verifyEvidence?.quota,
|
|
212
|
+
verifyCommandCount: verifyEvidence?.commandCount,
|
|
213
|
+
verifyCommandLabels: verifyEvidence?.commandLabels,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function fingerprintFromPassHistory(pass) {
|
|
217
|
+
return hardVerifyFailureFingerprint({
|
|
218
|
+
failureCategory: pass.hardVerifyFailureCategory,
|
|
219
|
+
verifyPhase: pass.verifyPhase,
|
|
220
|
+
verifyQuota: pass.verifyQuota,
|
|
221
|
+
verifyCommandCount: pass.verifyCommandCount,
|
|
222
|
+
verifyCommandLabels: pass.verifyCommandLabels,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
async function readRepairChangedFiles(runDir, repairNodeId = "repair-pi") {
|
|
226
|
+
try {
|
|
227
|
+
const raw = await readFile(path.join(runDir, repairNodeId, "change-manifest.json"), "utf-8");
|
|
228
|
+
const parsed = JSON.parse(raw);
|
|
229
|
+
if (!Array.isArray(parsed.changedFiles))
|
|
230
|
+
return [];
|
|
231
|
+
return parsed.changedFiles.filter((entry) => typeof entry === "string");
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
181
237
|
async function handleHardVerifyFailure(input, hardVerify) {
|
|
182
238
|
const convergence = input.state.convergence;
|
|
183
239
|
const currentPass = convergence.currentPass || 1;
|
|
184
240
|
const hardFailure = hardVerify.failureCategory ?? "unknown";
|
|
241
|
+
const processSupervisor = input.state.nodes["process-supervisor-pi"];
|
|
242
|
+
const repairArtifact = processSupervisor?.repairArtifact;
|
|
243
|
+
const disposition = repairDispositionOf(repairArtifact);
|
|
185
244
|
const passRecord = await buildConvergencePassRecord({
|
|
186
245
|
pass: currentPass,
|
|
187
246
|
status: "retrying",
|
|
@@ -198,6 +257,36 @@ async function handleHardVerifyFailure(input, hardVerify) {
|
|
|
198
257
|
await input.persistState();
|
|
199
258
|
return { retry: false };
|
|
200
259
|
}
|
|
260
|
+
// AC-002: blocked-boundary stops re-entry of same-permission repair.
|
|
261
|
+
if (disposition === "blocked-boundary") {
|
|
262
|
+
passRecord.status = "terminal";
|
|
263
|
+
passRecord.reason = "blocked-boundary";
|
|
264
|
+
convergence.passHistory.push(passRecord);
|
|
265
|
+
convergence.terminalReason = "blocked-boundary";
|
|
266
|
+
await input.persistState();
|
|
267
|
+
return { retry: false };
|
|
268
|
+
}
|
|
269
|
+
// AC-003: identical hard-verify failure fingerprint + empty repair
|
|
270
|
+
// change-manifest => no-progress early stop (does not exhaust maxFixLoops).
|
|
271
|
+
const currentFingerprint = fingerprintFromHardVerify(hardVerify);
|
|
272
|
+
const previousFailed = [...convergence.passHistory]
|
|
273
|
+
.reverse()
|
|
274
|
+
.find((pass) => pass.hardVerifyStatus === "ERROR");
|
|
275
|
+
const previousFingerprint = previousFailed
|
|
276
|
+
? fingerprintFromPassHistory(previousFailed)
|
|
277
|
+
: undefined;
|
|
278
|
+
const changedFiles = await readRepairChangedFiles(input.runDir);
|
|
279
|
+
if (previousFingerprint !== undefined &&
|
|
280
|
+
previousFingerprint === currentFingerprint &&
|
|
281
|
+
changedFiles !== undefined &&
|
|
282
|
+
changedFiles.length === 0) {
|
|
283
|
+
passRecord.status = "terminal";
|
|
284
|
+
passRecord.reason = "no-progress";
|
|
285
|
+
convergence.passHistory.push(passRecord);
|
|
286
|
+
convergence.terminalReason = "no-progress";
|
|
287
|
+
await input.persistState();
|
|
288
|
+
return { retry: false };
|
|
289
|
+
}
|
|
201
290
|
if (input.spec.convergence?.pauseOnRegression !== false &&
|
|
202
291
|
detectConvergenceRegression(convergence.passHistory, passRecord)) {
|
|
203
292
|
passRecord.status = "paused";
|
|
@@ -239,6 +328,14 @@ function isLegitimateReviewRequestRevision(state) {
|
|
|
239
328
|
reviewVerdictNode?.status === "FINISHED" &&
|
|
240
329
|
parseProcessVerdict(reviewVerdictNode) === "request-revision");
|
|
241
330
|
}
|
|
331
|
+
function isLegitimateReviewPass(state) {
|
|
332
|
+
const reviewNode = state.nodes["review-pi"];
|
|
333
|
+
const reviewVerdictNode = state.nodes[REVIEW_VERDICT_NODE_ID];
|
|
334
|
+
return (reviewNode?.status === "FINISHED" &&
|
|
335
|
+
parseProcessVerdict(reviewNode) === "pass" &&
|
|
336
|
+
reviewVerdictNode?.status === "FINISHED" &&
|
|
337
|
+
parseProcessVerdict(reviewVerdictNode) === "pass");
|
|
338
|
+
}
|
|
242
339
|
/**
|
|
243
340
|
* Hard verification passed but the review path produced a legitimate
|
|
244
341
|
* `request-revision`. With routingAccept the review gate finishes as a routing
|
|
@@ -34,43 +34,31 @@ export function isFrontendRepairable(failureClass) {
|
|
|
34
34
|
}
|
|
35
35
|
export function classifyFrontendFailure(input) {
|
|
36
36
|
const blob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stdout ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
|
|
37
|
-
const failureDiagnosticBlob = `${input.nodeId}\n${input.failureCategory ?? ""}\n${input.stderr ?? ""}`.toLowerCase();
|
|
38
37
|
if (input.nodeId.includes("contract") ||
|
|
39
38
|
blob.includes("contract mismatch") ||
|
|
40
39
|
blob.includes("source binding")) {
|
|
41
40
|
return "contract";
|
|
42
41
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
failureDiagnosticBlob.includes("write-set") ||
|
|
47
|
-
failureDiagnosticBlob.includes("writeset")) {
|
|
42
|
+
// Shell verification may contain test-runner logs from unrelated tests. Only
|
|
43
|
+
// the shell executor's structured category proves a write-boundary failure.
|
|
44
|
+
if (input.failureCategory === "write-guard") {
|
|
48
45
|
return "path";
|
|
49
46
|
}
|
|
50
|
-
if (blob.includes("package.json") ||
|
|
51
|
-
blob.includes("unapproved dependency") ||
|
|
52
|
-
blob.includes("npm install")) {
|
|
53
|
-
return "dependency";
|
|
54
|
-
}
|
|
55
|
-
if (blob.includes("credential") ||
|
|
56
|
-
blob.includes("api key") ||
|
|
57
|
-
blob.includes("secret")) {
|
|
58
|
-
return "credential";
|
|
59
|
-
}
|
|
60
|
-
if (blob.includes("deploy") || blob.includes("production release")) {
|
|
61
|
-
return "deploy";
|
|
62
|
-
}
|
|
63
|
-
if (blob.includes("spec unclear") ||
|
|
64
|
-
blob.includes("requirement ambiguous") ||
|
|
65
|
-
blob.includes("needs human")) {
|
|
66
|
-
return "spec-unclear";
|
|
67
|
-
}
|
|
68
47
|
if (input.nodeId.includes("trace") || blob.includes("trace:")) {
|
|
69
48
|
return "trace";
|
|
70
49
|
}
|
|
71
50
|
if (input.nodeId.includes("review")) {
|
|
72
51
|
return "review";
|
|
73
52
|
}
|
|
53
|
+
// Classify the failed verification command before inspecting its output.
|
|
54
|
+
// Full test output includes unrelated passing-test titles such as
|
|
55
|
+
// "secret-shaped" and "write-guard failure".
|
|
56
|
+
if (blob.includes("vitest") ||
|
|
57
|
+
blob.includes("jest") ||
|
|
58
|
+
blob.includes("npm test") ||
|
|
59
|
+
input.nodeId.includes("behavior")) {
|
|
60
|
+
return "unit-test";
|
|
61
|
+
}
|
|
74
62
|
if (blob.includes("typecheck") ||
|
|
75
63
|
blob.includes("tsc") ||
|
|
76
64
|
/error ts\d+/i.test(blob)) {
|
|
@@ -89,11 +77,23 @@ export function classifyFrontendFailure(input) {
|
|
|
89
77
|
blob.includes("render(")) {
|
|
90
78
|
return "component-test";
|
|
91
79
|
}
|
|
92
|
-
if (blob.includes("
|
|
93
|
-
blob.includes("
|
|
94
|
-
blob.includes("npm
|
|
95
|
-
|
|
96
|
-
|
|
80
|
+
if (blob.includes("package.json") ||
|
|
81
|
+
blob.includes("unapproved dependency") ||
|
|
82
|
+
blob.includes("npm install")) {
|
|
83
|
+
return "dependency";
|
|
84
|
+
}
|
|
85
|
+
if (blob.includes("credential") ||
|
|
86
|
+
blob.includes("api key") ||
|
|
87
|
+
blob.includes("secret")) {
|
|
88
|
+
return "credential";
|
|
89
|
+
}
|
|
90
|
+
if (blob.includes("deploy") || blob.includes("production release")) {
|
|
91
|
+
return "deploy";
|
|
92
|
+
}
|
|
93
|
+
if (blob.includes("spec unclear") ||
|
|
94
|
+
blob.includes("requirement ambiguous") ||
|
|
95
|
+
blob.includes("needs human")) {
|
|
96
|
+
return "spec-unclear";
|
|
97
97
|
}
|
|
98
98
|
if (input.failureCategory === "invalid-output")
|
|
99
99
|
return "contract";
|