@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/application/dag/generate-task-dag.js +33 -0
  3. package/dist/commands/task-source-prepare.js +6 -0
  4. package/dist/executors/dag-pi-executor.js +156 -9
  5. package/dist/executors/shell-executor.js +111 -0
  6. package/dist/executors/shell-presets.js +12 -4
  7. package/dist/executors/shell-write-guard.js +145 -12
  8. package/dist/task/config-types.js +6 -0
  9. package/dist/task/contract/constants.js +1 -0
  10. package/dist/task/contract/project.js +8 -0
  11. package/dist/task/contract/schema.js +1 -0
  12. package/dist/task/frontend-preflight.js +131 -0
  13. package/dist/task/runtime.js +2 -4
  14. package/dist/task/source-prepare/build-draft.js +9 -0
  15. package/dist/task/source-prepare/completeness.js +1 -1
  16. package/dist/worker/observability/read-model.js +134 -0
  17. package/dist/worker/observe/static/state.js +61 -0
  18. package/dist/worker/observe/static/styles.css +8 -0
  19. package/dist/worker/observe/static/views/dag-graph.js +107 -31
  20. package/dist/worker/observe/static/views/dag-inspector.js +374 -157
  21. package/dist/worker/observe/static/views/dag.js +4 -11
  22. package/dist/workflows/dag/backend-test-pytest-collection.js +277 -0
  23. package/dist/workflows/dag/convergence/controller.js +110 -21
  24. package/dist/workflows/dag/frontend-implementation-contract.js +218 -17
  25. package/dist/workflows/dag/frontend-repair.js +29 -29
  26. package/dist/workflows/dag/frontend-review-context.js +7 -1
  27. package/dist/workflows/dag/frontend-verification-trace.js +26 -5
  28. package/dist/workflows/dag/frontend-worktree-diff.js +14 -3
  29. package/dist/workflows/dag/governance-profile.js +1 -1
  30. package/dist/workflows/dag/init-hybrid.js +115 -39
  31. package/dist/workflows/dag/output-protocol.js +180 -7
  32. package/dist/workflows/dag/runner.js +141 -52
  33. package/dist/workflows/dag/types.js +5 -1
  34. package/dist/workflows/dag/validate.js +3 -2
  35. package/docs/templates/backend-test-dag.json +100 -8
  36. package/package.json +1 -1
  37. 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" || normalized.startsWith(".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" || normalized.startsWith(".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 ?? requiredWindowsEnvironment(process.env),
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" && exitCode !== undefined && (exitCode >>> 0) === 0xc0000142;
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 ? detail : `${detail.slice(0, 1000)}...[truncated]`;
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),
@@ -13,6 +13,7 @@ export const MANAGED_TASK_CONFIG_FIELDS = [
13
13
  "taskKind",
14
14
  "featureId",
15
15
  "allowedPaths",
16
+ "allowedRoots",
16
17
  "forbiddenPaths",
17
18
  "hardConstraints",
18
19
  "verifyCommands",
@@ -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
+ }
@@ -201,10 +201,8 @@ export function parseTaskConfigForRuntime(rawConfig) {
201
201
  }
202
202
  return parsed;
203
203
  }
204
- export function deriveMaxFixLoops(complexity) {
205
- if (complexity === 'small')
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++) {