@tea-agent/loop-agent 0.11.0 → 0.13.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +33 -4
  3. package/dist/application/dag/generate-task-dag.js +45 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/cli/command-definitions.js +10 -3
  7. package/dist/commands/init.js +74 -7
  8. package/dist/commands/knowledge.js +129 -31
  9. package/dist/governance/manifest-types.js +3 -0
  10. package/dist/shared/package-metadata.js +135 -0
  11. package/dist/task/config-types.js +6 -1
  12. package/dist/worker/cli.js +99 -2
  13. package/dist/worker/delivery/package.js +3 -3
  14. package/dist/worker/feature/decision-loader.js +37 -6
  15. package/dist/worker/feature/next-action.js +10 -2
  16. package/dist/worker/feature/ready-plan-projection.js +81 -0
  17. package/dist/worker/feature/reducer.js +2 -1
  18. package/dist/worker/feature/review.js +19 -2
  19. package/dist/worker/feature/run.js +27 -2
  20. package/dist/worker/follow-up/approve.js +5 -2
  21. package/dist/worker/follow-up/factory.js +1 -1
  22. package/dist/worker/observability/event-history.js +216 -0
  23. package/dist/worker/observability/read-model.js +552 -118
  24. package/dist/worker/observe/paths.js +17 -0
  25. package/dist/worker/observe/routes.js +310 -23
  26. package/dist/worker/observe/server.js +59 -1
  27. package/dist/worker/observe/spec-evidence.js +281 -0
  28. package/dist/worker/observe/static/api.js +46 -0
  29. package/dist/worker/observe/static/app.js +120 -2598
  30. package/dist/worker/observe/static/constants.js +148 -0
  31. package/dist/worker/observe/static/copy.js +67 -0
  32. package/dist/worker/observe/static/dag-helpers.js +172 -0
  33. package/dist/worker/observe/static/dag-model.js +72 -0
  34. package/dist/worker/observe/static/dom.js +61 -0
  35. package/dist/worker/observe/static/format-pool.js +67 -0
  36. package/dist/worker/observe/static/format.js +292 -0
  37. package/dist/worker/observe/static/index.html +300 -82
  38. package/dist/worker/observe/static/kpi.js +94 -0
  39. package/dist/worker/observe/static/relations.js +133 -0
  40. package/dist/worker/observe/static/router.js +93 -0
  41. package/dist/worker/observe/static/run-processing.js +148 -0
  42. package/dist/worker/observe/static/shell-chrome.js +68 -0
  43. package/dist/worker/observe/static/state.js +253 -0
  44. package/dist/worker/observe/static/styles.css +1731 -495
  45. package/dist/worker/observe/static/views/batch.js +227 -0
  46. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  47. package/dist/worker/observe/static/views/dag-inspector.js +596 -0
  48. package/dist/worker/observe/static/views/dag.js +362 -0
  49. package/dist/worker/observe/static/views/dashboard.js +445 -0
  50. package/dist/worker/observe/static/views/failures.js +143 -0
  51. package/dist/worker/observe/static/views/feature.js +492 -0
  52. package/dist/worker/observe/static/views/pool.js +350 -0
  53. package/dist/worker/observe/static/views/run.js +453 -0
  54. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  55. package/dist/worker/observe/static/views/shell.js +7 -0
  56. package/dist/worker/observe/static/views/task.js +314 -0
  57. package/dist/worker/observe/static/views/timeline.js +163 -0
  58. package/dist/worker/pool/doctor.js +165 -0
  59. package/dist/worker/pool/migrate-state.js +303 -0
  60. package/dist/worker/pool/run-store.js +205 -17
  61. package/dist/worker/pool/types.js +17 -1
  62. package/dist/worker/pool/validation.js +100 -15
  63. package/dist/worker/report/morning-report.js +12 -2
  64. package/dist/worker/runner/run-ready.js +41 -26
  65. package/dist/worker/task-graph/ready-planner.js +136 -0
  66. package/dist/workflows/dag/controller-identity.js +104 -0
  67. package/dist/workflows/dag/convergence/controller.js +16 -8
  68. package/dist/workflows/dag/failure-routing.js +12 -1
  69. package/dist/workflows/dag/init-hybrid.js +1233 -11
  70. package/dist/workflows/dag/node-execution.js +123 -29
  71. package/dist/workflows/dag/repair-artifact.js +91 -0
  72. package/dist/workflows/dag/report.js +50 -0
  73. package/dist/workflows/dag/retry-policy.js +138 -0
  74. package/dist/workflows/dag/runner.js +32 -0
  75. package/dist/workflows/dag/runtime-contract.js +87 -0
  76. package/dist/workflows/dag/skill-snapshot.js +2 -0
  77. package/dist/workflows/dag/types.js +45 -1
  78. package/dist/workflows/dag/validate.js +68 -4
  79. package/docs/README.md +1 -1
  80. package/docs/agent-dag-recovery-playbook.md +9 -0
  81. package/docs/agent-dag-runner.md +26 -1
  82. package/docs/architecture/dag-execution.md +6 -0
  83. package/docs/architecture/evolution.md +7 -5
  84. package/docs/architecture/facts-and-state.md +15 -2
  85. package/docs/architecture/worker-and-feature.md +6 -2
  86. package/docs/decisions/README.md +3 -0
  87. package/docs/design/README.md +12 -3
  88. package/docs/exec-plans/active/README.md +2 -2
  89. package/docs/exec-plans/completed/README.md +12 -0
  90. package/docs/feature-workflow.md +108 -2
  91. package/docs/loop-agent-harness.md +15 -4
  92. package/docs/progress/README.md +22 -0
  93. package/docs/reports/README.md +14 -2
  94. package/docs/templates/agent-dag-report.schema.json +17 -0
  95. package/docs/templates/agent-dag.schema.json +69 -1
  96. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  97. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  98. package/docs/templates/backend-test-dag.json +288 -0
  99. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  100. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  101. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
  102. package/docs/templates/knowledge-sync-dag.json +177 -0
  103. package/docs/templates/knowledge-sync-draft.schema.json +71 -0
  104. package/docs/verification-matrix.md +2 -1
  105. package/package.json +8 -2
  106. package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
  107. package/scripts/kb-graph-incremental-prepare.mjs +372 -0
  108. package/scripts/kb-graph-incremental-prepare.sh +5 -0
  109. package/scripts/kb-graph-materialize.mjs +105 -0
  110. package/scripts/kb-graph-materialize.sh +4 -0
  111. package/scripts/kb-graph-promote.mjs +153 -0
  112. package/scripts/kb-graph-promote.sh +4 -0
  113. package/scripts/kb-query.mjs +554 -0
  114. package/scripts/kb-query.sh +5 -0
  115. package/skills/agent-worker/SKILL.md +3 -1
  116. package/skills/agent-worker/references/agent-worker-operator.md +18 -1
  117. package/skills/frontend-design-review/SKILL.md +26 -24
  118. package/skills/frontend-implementation/SKILL.md +29 -26
  119. package/skills/frontend-implementation/references/node-contracts.md +50 -19
  120. package/skills/frontend-review/SKILL.md +1 -1
  121. package/skills/loop-agent/references/command-reference.md +2 -0
  122. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  123. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -3,10 +3,44 @@ import { readdir, readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { truncateUtf8Preview } from "../../shared/preview.js";
5
5
  import { parseWorkerEventLine } from "./events.js";
6
- import { getTaskPoolRoot } from "../pool/run-store.js";
6
+ import { readBoundedJsonlTail } from "./event-history.js";
7
+ import { getTaskPoolRoot, listLegacyStateFiles, listTaskPoolStates, } from "../pool/run-store.js";
7
8
  import { assessDagRunLiveness, assessDagRunRecoveryEligibility, deriveDagRunEffectiveStatus, } from "../../workflows/dag/lifecycle.js";
8
9
  import { parseDagSpec, resolveModelForTask, } from "../../workflows/dag/types.js";
9
10
  import { loadFeatureDecisionModels } from "../feature/decision-loader.js";
11
+ /** Internal map key: featureId + NUL + taskId (stable, non-printable delimiter). */
12
+ export function taskIdentityKey(featureId, taskId) {
13
+ return `${featureId}\0${taskId}`;
14
+ }
15
+ export function compareTaskIdentity(a, b) {
16
+ const fa = a.featureId ?? "";
17
+ const fb = b.featureId ?? "";
18
+ const byFeature = fa.localeCompare(fb);
19
+ if (byFeature !== 0)
20
+ return byFeature;
21
+ return a.taskId.localeCompare(b.taskId);
22
+ }
23
+ export function findTasksByTaskId(tasks, taskId) {
24
+ return tasks.filter((task) => task.taskId === taskId);
25
+ }
26
+ export function resolveLegacyTask(tasks, taskId) {
27
+ const matches = findTasksByTaskId(tasks, taskId);
28
+ const candidates = matches.map((task) => ({
29
+ featureId: task.featureId,
30
+ taskId: task.taskId,
31
+ status: task.status,
32
+ workerRunId: task.workerRunId,
33
+ }));
34
+ if (matches.length === 0) {
35
+ return { kind: "missing", candidates: [] };
36
+ }
37
+ if (matches.length === 1) {
38
+ return { kind: "unique", task: matches[0], candidates };
39
+ }
40
+ return { kind: "ambiguous", candidates };
41
+ }
42
+ export const TASK_RUN_HISTORY_DEFAULT_LIMIT = 20;
43
+ export const TASK_RUN_HISTORY_MAX_LIMIT = 100;
10
44
  const ACTIVE_TASK_STATUSES = new Set(["running", "pending"]);
11
45
  const ACTIVE_BATCH_STATUSES = new Set(["running", "pending"]);
12
46
  export async function buildGlobalSnapshot(options) {
@@ -15,7 +49,8 @@ export async function buildGlobalSnapshot(options) {
15
49
  const now = options.now ?? (() => new Date());
16
50
  const staleThresholdMs = options.staleThresholdMs ?? 90_000;
17
51
  const quietThresholdMs = options.quietThresholdMs ?? 60_000;
18
- const [events, ledgerRuns, ledgerBatches, states, handoffs, dagRuns] = await Promise.all([
52
+ const taskPoolPresent = existsSync(getTaskPoolRoot(repoRoot));
53
+ const [eventsLoad, ledgerLoad, ledgerBatches, statesLoad, handoffs, dagRuns,] = await Promise.all([
19
54
  loadObservabilityEvents(repoRoot),
20
55
  loadLedgerRuns(repoRoot),
21
56
  loadLedgerBatches(repoRoot),
@@ -23,29 +58,45 @@ export async function buildGlobalSnapshot(options) {
23
58
  loadFailureHandoffs(repoRoot),
24
59
  loadDagRuns(repoRoot, now()),
25
60
  ]);
61
+ const events = eventsLoad.events;
62
+ const ledgerRuns = ledgerLoad.runs;
63
+ const identityWarnings = [];
26
64
  const taskMap = new Map();
27
- for (const state of states) {
28
- mergeStateRecord(taskMap, state);
29
- }
65
+ // Ledger first so featureId-bearing rows seed composite identity before states/events.
30
66
  for (const run of ledgerRuns) {
31
- mergeLedgerRun(taskMap, run);
67
+ mergeLedgerRun(taskMap, run, identityWarnings);
68
+ }
69
+ for (const state of statesLoad.states) {
70
+ mergeStateRecord(taskMap, state, identityWarnings);
71
+ }
72
+ // Attach uniquely-resolvable legacy flat states after owned rows exist.
73
+ for (const state of statesLoad.legacyCandidates) {
74
+ mergeLegacyStateRecord(taskMap, state, identityWarnings);
32
75
  }
33
- mergeEvents(taskMap, events);
76
+ const workerRunFeatureIndex = buildWorkerRunFeatureIndex(ledgerRuns, taskMap);
77
+ mergeEvents(taskMap, events, workerRunFeatureIndex, identityWarnings);
34
78
  mergeHandoffs(taskMap, handoffs);
35
79
  applyStaleDetection(taskMap, now(), staleThresholdMs, quietThresholdMs);
36
- const historicalRunMap = buildHistoricalRunMap(ledgerRuns, events);
80
+ const historicalRunMap = buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex, identityWarnings);
37
81
  mergeHandoffs(historicalRunMap, handoffs);
38
82
  applyStaleDetection(historicalRunMap, now(), staleThresholdMs, quietThresholdMs);
39
83
  const tasks = [...taskMap.values()]
40
84
  .map(toPublicTaskSummary)
41
85
  .map((task) => normalizeTaskArtifactRefs(task, repoRoot))
42
- .sort((a, b) => a.taskId.localeCompare(b.taskId));
86
+ .sort(compareTaskIdentity);
43
87
  const batches = buildBatches(ledgerBatches, events, historicalRunMap).map((batch) => ({
44
88
  ...batch,
45
89
  tasks: batch.tasks.map((task) => normalizeTaskArtifactRefs(task, repoRoot)),
46
90
  }));
47
91
  const health = computeHealth(batches, tasks, dagRuns);
48
- const { features, projectionWarnings } = await loadFeatureDecisionModels(repoRoot);
92
+ const featureLoad = await loadFeatureDecisionModels(repoRoot);
93
+ const projectionWarnings = [
94
+ ...(eventsLoad.warnings ?? []),
95
+ ...(ledgerLoad.warnings ?? []),
96
+ ...(statesLoad.warnings ?? []),
97
+ ...identityWarnings,
98
+ ...(featureLoad.projectionWarnings ?? []),
99
+ ];
49
100
  return {
50
101
  schemaVersion: 1,
51
102
  generatedAt: now().toISOString(),
@@ -53,7 +104,8 @@ export async function buildGlobalSnapshot(options) {
53
104
  batches,
54
105
  tasks,
55
106
  dagRuns,
56
- features,
107
+ features: featureLoad.features,
108
+ taskPool: { present: taskPoolPresent },
57
109
  projectionWarnings,
58
110
  health,
59
111
  };
@@ -80,7 +132,105 @@ export async function buildGlobalSnapshot(options) {
80
132
  return snapshot;
81
133
  }
82
134
  }
135
+ /**
136
+ * Bounded Task run history from Task Pool ledger facts (newest first).
137
+ * Filtered by composite `{featureId, taskId}`.
138
+ * `before` is an exclusive upper bound on the sort key (`recordedAt` or empty).
139
+ */
140
+ export async function listTaskRunHistory(repoRoot, featureId, taskId, options = {}) {
141
+ const limit = clampTaskRunHistoryLimit(options.limit);
142
+ const before = decodeTaskRunHistoryCursor(options.before);
143
+ const [ledgerLoad, eventsLoad] = await Promise.all([
144
+ loadLedgerRuns(path.resolve(repoRoot)),
145
+ loadObservabilityEvents(path.resolve(repoRoot)),
146
+ ]);
147
+ const ledgerRuns = ledgerLoad.runs;
148
+ const events = eventsLoad.events;
149
+ const workerRunFeatureIndex = buildWorkerRunFeatureIndex(ledgerRuns);
150
+ const historical = buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex);
151
+ const items = [];
152
+ for (const run of ledgerRuns) {
153
+ if (run.taskId !== taskId)
154
+ continue;
155
+ if ((run.featureId ?? "") !== featureId)
156
+ continue;
157
+ const enriched = historical.get(run.workerRunId);
158
+ items.push({
159
+ workerRunId: run.workerRunId,
160
+ taskId: run.taskId,
161
+ featureId: run.featureId ?? featureId,
162
+ status: enriched?.status ?? mapRunRecordStatus(run.status),
163
+ startedAt: enriched?.startedAt,
164
+ finishedAt: enriched?.finishedAt ?? run.recordedAt,
165
+ recordedAt: run.recordedAt,
166
+ batchRunId: run.batchRunId,
167
+ dagRunId: enriched?.dagRunId,
168
+ ...(run.retryOfWorkerRunId
169
+ ? { retryOfWorkerRunId: run.retryOfWorkerRunId }
170
+ : {}),
171
+ failureCategory: run.failureCategory ?? enriched?.failureCategory,
172
+ recommendedFollowUp: run.recommendedFollowUp ?? enriched?.recommendedFollowUp,
173
+ });
174
+ }
175
+ items.sort((a, b) => {
176
+ const ta = a.recordedAt ?? a.finishedAt ?? "";
177
+ const tb = b.recordedAt ?? b.finishedAt ?? "";
178
+ if (ta !== tb)
179
+ return tb.localeCompare(ta);
180
+ return b.workerRunId.localeCompare(a.workerRunId);
181
+ });
182
+ const eligible = before
183
+ ? items.filter((item) => taskRunSortKey(item).localeCompare(before) < 0)
184
+ : items;
185
+ const page = eligible.slice(0, limit);
186
+ const nextBefore = eligible.length > limit
187
+ ? encodeTaskRunHistoryCursor(page[page.length - 1])
188
+ : null;
189
+ return {
190
+ featureId,
191
+ taskId,
192
+ runs: page,
193
+ limit,
194
+ nextBefore,
195
+ };
196
+ }
197
+ function taskRunSortKey(item) {
198
+ return `${item.recordedAt ?? item.finishedAt ?? ""}\u0000${item.workerRunId}`;
199
+ }
200
+ function encodeTaskRunHistoryCursor(item) {
201
+ if (!item)
202
+ return null;
203
+ return `v1.${Buffer.from(taskRunSortKey(item), "utf-8").toString("base64url")}`;
204
+ }
205
+ function decodeTaskRunHistoryCursor(cursor) {
206
+ const value = cursor?.trim();
207
+ if (!value)
208
+ return null;
209
+ if (!value.startsWith("v1.")) {
210
+ // Compatibility with the initial timestamp-only cursor shape.
211
+ return `${value}\u0000`;
212
+ }
213
+ try {
214
+ return Buffer.from(value.slice(3), "base64url").toString("utf-8");
215
+ }
216
+ catch {
217
+ return `${value}\u0000`;
218
+ }
219
+ }
220
+ export function clampTaskRunHistoryLimit(limit) {
221
+ if (limit === undefined || !Number.isFinite(limit) || limit <= 0) {
222
+ return TASK_RUN_HISTORY_DEFAULT_LIMIT;
223
+ }
224
+ return Math.min(Math.floor(limit), TASK_RUN_HISTORY_MAX_LIMIT);
225
+ }
83
226
  function emptySnapshot(repoRoot, now) {
227
+ let taskPoolPresent = false;
228
+ try {
229
+ taskPoolPresent = existsSync(getTaskPoolRoot(repoRoot));
230
+ }
231
+ catch {
232
+ taskPoolPresent = false;
233
+ }
84
234
  return {
85
235
  schemaVersion: 1,
86
236
  generatedAt: now().toISOString(),
@@ -88,6 +238,7 @@ function emptySnapshot(repoRoot, now) {
88
238
  batches: [],
89
239
  tasks: [],
90
240
  dagRuns: [],
241
+ taskPool: { present: taskPoolPresent },
91
242
  health: {
92
243
  activeBatches: 0,
93
244
  activeTasks: 0,
@@ -118,15 +269,29 @@ function emptySnapshot(repoRoot, now) {
118
269
  };
119
270
  }
120
271
  function toPublicTaskSummary(task) {
121
- const { batchRunId: _batch, timeoutMs: _to, commandStartedAt: _cs, ...rest } = task;
272
+ const { timeoutMs: _to, commandStartedAt: _cs, ...rest } = task;
122
273
  return rest;
123
274
  }
124
- function mergeStateRecord(taskMap, state) {
125
- const existing = taskMap.get(state.taskId) ?? { taskId: state.taskId, status: "unknown" };
126
- taskMap.set(state.taskId, {
275
+ function mergeStateRecord(taskMap, state, warnings) {
276
+ const featureId = state.featureId?.trim();
277
+ if (!featureId) {
278
+ warnings?.push(`state for task ${state.taskId} missing featureId; excluded from composite projection`);
279
+ return;
280
+ }
281
+ const key = taskIdentityKey(featureId, state.taskId);
282
+ const existing = taskMap.get(key) ?? {
283
+ taskId: state.taskId,
284
+ featureId,
285
+ status: "unknown",
286
+ };
287
+ taskMap.set(key, {
127
288
  ...existing,
128
- status: existing.status !== "unknown" ? existing.status : mapStateStatus(state.status),
289
+ featureId,
290
+ status: existing.status !== "unknown"
291
+ ? existing.status
292
+ : mapStateStatus(state.status),
129
293
  workerRunId: existing.workerRunId ?? state.workerRunId,
294
+ updatedAt: state.updatedAt ?? existing.updatedAt,
130
295
  artifactRefs: mergeArtifactRefs(existing.artifactRefs, {
131
296
  runRecordPath: state.lastRunRecordPath,
132
297
  }),
@@ -134,46 +299,140 @@ function mergeStateRecord(taskMap, state) {
134
299
  recommendedFollowUp: existing.recommendedFollowUp ?? state.recommendedFollowUp,
135
300
  });
136
301
  }
137
- function mergeLedgerRun(taskMap, run) {
138
- const existing = taskMap.get(run.taskId) ?? { taskId: run.taskId, status: "unknown" };
302
+ /**
303
+ * Legacy flat states/<taskId>.json: only merge when the taskId already maps to
304
+ * exactly one composite row in the snapshot (unique ownership). Otherwise warn+exclude.
305
+ */
306
+ function mergeLegacyStateRecord(taskMap, state, warnings) {
307
+ const owners = [...taskMap.values()].filter((task) => task.taskId === state.taskId && Boolean(task.featureId));
308
+ if (owners.length === 1) {
309
+ const owner = owners[0];
310
+ mergeStateRecord(taskMap, { ...state, featureId: owner.featureId }, warnings);
311
+ warnings.push(`legacy flat state for task ${state.taskId} attached to unique feature ${owner.featureId}`);
312
+ return;
313
+ }
314
+ if (owners.length === 0) {
315
+ // No owned composite row yet: still surface as a warning and do not invent featureId.
316
+ warnings.push(`legacy flat state for task ${state.taskId} has no unique feature ownership; excluded from composite projection`);
317
+ return;
318
+ }
319
+ warnings.push(`legacy flat state for task ${state.taskId} is ambiguous across features; excluded from composite projection`);
320
+ }
321
+ function mergeLedgerRun(taskMap, run, warnings) {
322
+ const featureId = run.featureId?.trim();
323
+ if (!featureId) {
324
+ warnings?.push(`ledger run ${run.workerRunId} for task ${run.taskId} missing featureId; excluded from composite projection`);
325
+ return;
326
+ }
327
+ const key = taskIdentityKey(featureId, run.taskId);
328
+ const existing = taskMap.get(key) ?? {
329
+ taskId: run.taskId,
330
+ featureId,
331
+ status: "unknown",
332
+ };
139
333
  const sameRun = existing.workerRunId === run.workerRunId;
140
334
  const runArtifactRefs = {
141
335
  runRecordPath: run.runRecordPath,
142
336
  dagPath: run.dagPath,
143
337
  ...run.failureArtifacts,
144
338
  };
145
- taskMap.set(run.taskId, {
339
+ taskMap.set(key, {
146
340
  ...(sameRun
147
341
  ? existing
148
342
  : {
149
343
  taskId: run.taskId,
344
+ featureId,
150
345
  status: "unknown",
151
- featureId: existing.featureId,
152
346
  harnessTaskId: existing.harnessTaskId,
153
347
  }),
154
348
  batchRunId: run.batchRunId,
155
- featureId: run.featureId ?? existing.featureId,
349
+ featureId,
156
350
  status: mapRunRecordStatus(run.status),
157
351
  workerRunId: run.workerRunId,
158
352
  harnessTaskId: run.harnessTaskId ?? existing.harnessTaskId,
159
353
  finishedAt: run.recordedAt ?? (sameRun ? existing.finishedAt : undefined),
160
354
  failureCategory: run.failureCategory ?? (sameRun ? existing.failureCategory : undefined),
161
- recommendedFollowUp: run.recommendedFollowUp ?? (sameRun ? existing.recommendedFollowUp : undefined),
355
+ recommendedFollowUp: run.recommendedFollowUp ??
356
+ (sameRun ? existing.recommendedFollowUp : undefined),
162
357
  artifactRefs: mergeArtifactRefs(sameRun ? existing.artifactRefs : undefined, runArtifactRefs),
163
358
  });
164
359
  }
165
- function mergeEvents(taskMap, events) {
360
+ /**
361
+ * Build workerRunId → featureId unique index from ledger + already-owned task rows.
362
+ * Ambiguous (0 or N) mappings are omitted so callers treat them as unowned.
363
+ */
364
+ function buildWorkerRunFeatureIndex(ledgerRuns, taskMap) {
365
+ const candidates = new Map();
366
+ const add = (workerRunId, featureId) => {
367
+ const wr = workerRunId?.trim();
368
+ const fid = featureId?.trim();
369
+ if (!wr || !fid)
370
+ return;
371
+ let set = candidates.get(wr);
372
+ if (!set) {
373
+ set = new Set();
374
+ candidates.set(wr, set);
375
+ }
376
+ set.add(fid);
377
+ };
378
+ for (const run of ledgerRuns) {
379
+ add(run.workerRunId, run.featureId);
380
+ }
381
+ if (taskMap) {
382
+ for (const task of taskMap.values()) {
383
+ add(task.workerRunId, task.featureId);
384
+ }
385
+ }
386
+ const unique = new Map();
387
+ for (const [workerRunId, features] of candidates) {
388
+ if (features.size === 1) {
389
+ unique.set(workerRunId, [...features][0]);
390
+ }
391
+ }
392
+ return unique;
393
+ }
394
+ function mergeEvents(taskMap, events, workerRunFeatureIndex, warnings) {
166
395
  const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
167
396
  for (const event of sorted) {
168
397
  if (event.taskId) {
169
- applyTaskEvent(taskMap, event);
398
+ applyTaskEvent(taskMap, event, workerRunFeatureIndex, warnings);
170
399
  }
171
400
  }
172
401
  }
173
- function applyTaskEvent(taskMap, event) {
402
+ function applyTaskEvent(taskMap, event, workerRunFeatureIndex, warnings) {
403
+ const taskId = event.taskId;
404
+ const featureId = resolveEventFeatureId(event, taskMap, workerRunFeatureIndex, warnings);
405
+ if (!featureId)
406
+ return;
407
+ const key = taskIdentityKey(featureId, taskId);
408
+ const existing = taskMap.get(key) ?? {
409
+ taskId,
410
+ featureId,
411
+ status: "unknown",
412
+ };
413
+ taskMap.set(key, applyEventToSummary(existing, event));
414
+ }
415
+ function resolveEventFeatureId(event, taskMap, workerRunFeatureIndex, warnings) {
174
416
  const taskId = event.taskId;
175
- const existing = taskMap.get(taskId) ?? { taskId, status: "unknown" };
176
- taskMap.set(taskId, applyEventToSummary(existing, event));
417
+ if (!taskId)
418
+ return undefined;
419
+ if (event.workerRunId) {
420
+ const fromIndex = workerRunFeatureIndex.get(event.workerRunId);
421
+ if (fromIndex)
422
+ return fromIndex;
423
+ // Fall through: maybe a single existing composite row already owns this workerRunId.
424
+ const byWorkerRun = [...taskMap.values()].filter((task) => task.workerRunId === event.workerRunId && Boolean(task.featureId));
425
+ if (byWorkerRun.length === 1)
426
+ return byWorkerRun[0].featureId;
427
+ }
428
+ // Unique taskId ownership in current map (single Feature).
429
+ const byTaskId = [...taskMap.values()].filter((task) => task.taskId === taskId && Boolean(task.featureId));
430
+ if (byTaskId.length === 1)
431
+ return byTaskId[0].featureId;
432
+ warnings?.push(`unowned event ${event.type} for task ${taskId}` +
433
+ (event.workerRunId ? ` workerRunId ${event.workerRunId}` : "") +
434
+ ` cannot uniquely resolve featureId; excluded from task projection`);
435
+ return undefined;
177
436
  }
178
437
  function applyEventToSummary(existing, event) {
179
438
  const updated = {
@@ -224,9 +483,10 @@ function applyEventToSummary(existing, event) {
224
483
  }
225
484
  return updated;
226
485
  }
227
- function buildHistoricalRunMap(ledgerRuns, events) {
486
+ function buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex = new Map(), warnings) {
228
487
  const runMap = new Map();
229
488
  for (const run of ledgerRuns) {
489
+ // History map is keyed by workerRunId (run-scoped); featureId still required when present.
230
490
  runMap.set(run.workerRunId, {
231
491
  taskId: run.taskId,
232
492
  batchRunId: run.batchRunId,
@@ -247,12 +507,23 @@ function buildHistoricalRunMap(ledgerRuns, events) {
247
507
  for (const event of [...events].sort((a, b) => a.at.localeCompare(b.at))) {
248
508
  if (!event.workerRunId || !event.taskId)
249
509
  continue;
250
- const existing = runMap.get(event.workerRunId) ?? {
510
+ const existing = runMap.get(event.workerRunId);
511
+ if (existing) {
512
+ runMap.set(event.workerRunId, applyEventToSummary(existing, event));
513
+ continue;
514
+ }
515
+ // Only invent a historical row when workerRunId uniquely maps to a feature.
516
+ const featureId = workerRunFeatureIndex.get(event.workerRunId);
517
+ if (!featureId) {
518
+ warnings?.push(`unowned historical event ${event.type} for task ${event.taskId} workerRunId ${event.workerRunId} cannot uniquely resolve featureId; excluded from historical run map`);
519
+ continue;
520
+ }
521
+ runMap.set(event.workerRunId, applyEventToSummary({
251
522
  taskId: event.taskId,
523
+ featureId,
252
524
  workerRunId: event.workerRunId,
253
525
  status: "unknown",
254
- };
255
- runMap.set(event.workerRunId, applyEventToSummary(existing, event));
526
+ }, event));
256
527
  }
257
528
  return runMap;
258
529
  }
@@ -320,7 +591,9 @@ function computeLiveness(task, nowMs, staleThresholdMs, quietThresholdMs) {
320
591
  task.timeoutAt = new Date(commandStartedMs + timeoutMs).toISOString();
321
592
  }
322
593
  }
323
- const heartbeatMs = task.lastHeartbeatAt ? Date.parse(task.lastHeartbeatAt) : NaN;
594
+ const heartbeatMs = task.lastHeartbeatAt
595
+ ? Date.parse(task.lastHeartbeatAt)
596
+ : NaN;
324
597
  const outputMs = task.lastOutputAt ? Date.parse(task.lastOutputAt) : NaN;
325
598
  // Priority 1: timeout-risk (elapsed > 0.8 * timeoutMs), only if timeoutMs known.
326
599
  if (timeoutMs !== undefined &&
@@ -333,7 +606,9 @@ function computeLiveness(task, nowMs, staleThresholdMs, quietThresholdMs) {
333
606
  // (a) Heartbeat present but older than staleThresholdMs.
334
607
  // (b) No heartbeat but running longer than staleThresholdMs (startedAt known).
335
608
  const heartbeatStale = !Number.isNaN(heartbeatMs) && nowMs - heartbeatMs > staleThresholdMs;
336
- const noHeartbeatStale = Number.isNaN(heartbeatMs) && elapsedMs !== undefined && elapsedMs > staleThresholdMs;
609
+ const noHeartbeatStale = Number.isNaN(heartbeatMs) &&
610
+ elapsedMs !== undefined &&
611
+ elapsedMs > staleThresholdMs;
337
612
  if (heartbeatStale || noHeartbeatStale) {
338
613
  task.liveness = "stale";
339
614
  task.status = "stale";
@@ -390,7 +665,7 @@ function buildBatches(ledgerBatches, events, taskMap) {
390
665
  batch.tasks.push(toPublicTaskSummary(task));
391
666
  }
392
667
  for (const batch of batchMap.values()) {
393
- batch.tasks.sort((a, b) => a.taskId.localeCompare(b.taskId));
668
+ batch.tasks.sort(compareTaskIdentity);
394
669
  if (batch.summary === undefined && batch.tasks.length > 0) {
395
670
  batch.summary = summarizeTasks(batch.tasks);
396
671
  }
@@ -414,7 +689,8 @@ function summarizeTasks(tasks) {
414
689
  function computeHealth(batches, tasks, dagRuns) {
415
690
  const worker = {
416
691
  activeTasks: tasks.filter((t) => ACTIVE_TASK_STATUSES.has(t.status)).length,
417
- activeBatches: batches.filter((b) => ACTIVE_BATCH_STATUSES.has(b.status)).length,
692
+ activeBatches: batches.filter((b) => ACTIVE_BATCH_STATUSES.has(b.status))
693
+ .length,
418
694
  quietCount: tasks.filter((t) => t.liveness === "quiet").length,
419
695
  staleCount: tasks.filter((t) => t.status === "stale").length,
420
696
  timeoutRiskCount: tasks.filter((t) => t.liveness === "timeout-risk").length,
@@ -451,7 +727,8 @@ const TERMINAL_RUN_STATUSES = new Set([
451
727
  * already-loaded DagRunSummary facts without touching app.js or new files.
452
728
  */
453
729
  function isDagRunActiveForHealth(dag) {
454
- if (dag.effectiveStatus && ACTIVE_EFFECTIVE_STATUSES.has(dag.effectiveStatus)) {
730
+ if (dag.effectiveStatus &&
731
+ ACTIVE_EFFECTIVE_STATUSES.has(dag.effectiveStatus)) {
455
732
  return true;
456
733
  }
457
734
  // Only "unknown" (and absent) means liveness evidence is insufficient;
@@ -472,7 +749,16 @@ function isDagRunActiveForHealth(dag) {
472
749
  }
473
750
  function isNodeStatusActive(status) {
474
751
  const normalized = (status ?? "").toLowerCase();
475
- if (["finished", "completed", "succeeded", "done", "error", "failed", "skipped", "partial_failed"].includes(normalized)) {
752
+ if ([
753
+ "finished",
754
+ "completed",
755
+ "succeeded",
756
+ "done",
757
+ "error",
758
+ "failed",
759
+ "skipped",
760
+ "partial_failed",
761
+ ].includes(normalized)) {
476
762
  return false;
477
763
  }
478
764
  return true;
@@ -511,13 +797,13 @@ function computeDagHealth(dagRuns) {
511
797
  interruptedRuns++;
512
798
  if (dag.stateConsistent === false)
513
799
  inconsistentRuns++;
514
- if (lifecycle !== "completed"
515
- && (lifecycle === "paused"
516
- || liveness === "stale"
517
- || liveness === "orphaned"
518
- || dag.effectiveStatus === "interrupted"
519
- || dag.effectiveStatus === "remote-unknown"
520
- || dag.stateConsistent === false)) {
800
+ if (lifecycle !== "completed" &&
801
+ (lifecycle === "paused" ||
802
+ liveness === "stale" ||
803
+ liveness === "orphaned" ||
804
+ dag.effectiveStatus === "interrupted" ||
805
+ dag.effectiveStatus === "remote-unknown" ||
806
+ dag.stateConsistent === false)) {
521
807
  attentionRuns++;
522
808
  }
523
809
  }
@@ -547,12 +833,14 @@ function sanitizeProjectionError(error) {
547
833
  let name;
548
834
  let message;
549
835
  try {
550
- name = typeof error.name === "string"
551
- ? error.name
552
- : undefined;
553
- message = typeof error.message === "string"
554
- ? error.message
555
- : undefined;
836
+ name =
837
+ typeof error.name === "string"
838
+ ? error.name
839
+ : undefined;
840
+ message =
841
+ typeof error.message === "string"
842
+ ? error.message
843
+ : undefined;
556
844
  }
557
845
  catch {
558
846
  return { message: fallback, at };
@@ -563,12 +851,12 @@ function sanitizeProjectionError(error) {
563
851
  }
564
852
  // Reject anything that looks like a stack frame, an absolute path, or a secret.
565
853
  const raw = String(candidate);
566
- if (raw.includes("\n")
567
- || /\bat \S+/i.test(raw)
568
- || /[A-Za-z]:\\/.test(raw)
569
- || raw.includes("/Users/")
570
- || raw.includes("/home/")
571
- || /SECRET|TOKEN|PASSWORD|API_KEY/i.test(raw)) {
854
+ if (raw.includes("\n") ||
855
+ /\bat \S+/i.test(raw) ||
856
+ /[A-Za-z]:\\/.test(raw) ||
857
+ raw.includes("/Users/") ||
858
+ raw.includes("/home/") ||
859
+ /SECRET|TOKEN|PASSWORD|API_KEY/i.test(raw)) {
572
860
  return { message: name ? `${name}: 投影失败` : fallback, at };
573
861
  }
574
862
  const trimmed = raw.trim().slice(0, 200);
@@ -581,7 +869,11 @@ function mergeArtifactRefs(existing, incoming) {
581
869
  for (const [key, value] of Object.entries(incoming)) {
582
870
  if (!value)
583
871
  continue;
584
- if (key === "runRecordPath" || key === "dagPath" || key === "reportMarkdown" || key === "doctorMarkdown" || key === "closeoutDraft") {
872
+ if (key === "runRecordPath" ||
873
+ key === "dagPath" ||
874
+ key === "reportMarkdown" ||
875
+ key === "doctorMarkdown" ||
876
+ key === "closeoutDraft") {
585
877
  merged[key] = value;
586
878
  }
587
879
  }
@@ -645,6 +937,7 @@ function mapStateStatus(status) {
645
937
  case "Running":
646
938
  return "running";
647
939
  case "Queued":
940
+ case "Ready":
648
941
  return "pending";
649
942
  case "Done":
650
943
  return "succeeded";
@@ -668,49 +961,86 @@ function mapBatchStatus(status) {
668
961
  return "unknown";
669
962
  }
670
963
  }
964
+ /** Per-file hard cap for projection merge (snapshot must not embed unbounded ledger). */
965
+ const SNAPSHOT_EVENTS_MAX_LINES_PER_FILE = 2_000;
966
+ const SNAPSHOT_EVENTS_MAX_BYTES_PER_FILE = 4 * 1024 * 1024;
967
+ /** Global hard cap after dedupe for snapshot projection merge. */
968
+ const SNAPSHOT_EVENTS_MAX_TOTAL = 8_000;
969
+ const SNAPSHOT_LEDGER_MAX_LINES = 10_000;
970
+ const SNAPSHOT_LEDGER_MAX_BYTES = 8 * 1024 * 1024;
671
971
  async function loadObservabilityEvents(repoRoot) {
672
972
  const events = [];
973
+ const warnings = [];
673
974
  const obsRoot = path.join(getTaskPoolRoot(repoRoot), "observability");
674
- await appendJsonlEvents(path.join(obsRoot, "events.jsonl"), events);
975
+ await appendJsonlEventsBounded(path.join(obsRoot, "events.jsonl"), events, warnings);
675
976
  const batchesDir = path.join(obsRoot, "batches");
676
- await forEachSubdirJsonl(batchesDir, "events.jsonl", events);
977
+ await forEachSubdirJsonl(batchesDir, "events.jsonl", events, warnings);
677
978
  const runsDir = path.join(obsRoot, "runs");
678
- await forEachSubdirJsonl(runsDir, "events.jsonl", events);
679
- return [...new Map(events.map((event) => [event.id, event])).values()];
979
+ await forEachSubdirJsonl(runsDir, "events.jsonl", events, warnings);
980
+ const deduped = [
981
+ ...new Map(events.map((event) => [event.id, event])).values(),
982
+ ].sort(compareWorkerEvents);
983
+ if (deduped.length > SNAPSHOT_EVENTS_MAX_TOTAL) {
984
+ warnings.push(`observability event projection truncated to ${SNAPSHOT_EVENTS_MAX_TOTAL} events (capacity bound)`);
985
+ return {
986
+ events: deduped.slice(-SNAPSHOT_EVENTS_MAX_TOTAL),
987
+ warnings,
988
+ };
989
+ }
990
+ return { events: deduped, warnings };
991
+ }
992
+ function compareWorkerEvents(a, b) {
993
+ const byTime = a.at.localeCompare(b.at);
994
+ if (byTime !== 0)
995
+ return byTime;
996
+ return a.id.localeCompare(b.id);
680
997
  }
681
- async function forEachSubdirJsonl(parentDir, filename, events) {
998
+ async function forEachSubdirJsonl(parentDir, filename, events, warnings) {
682
999
  try {
683
1000
  const entries = await readdir(parentDir, { withFileTypes: true });
684
1001
  for (const entry of entries) {
685
1002
  if (!entry.isDirectory())
686
1003
  continue;
687
- await appendJsonlEvents(path.join(parentDir, entry.name, filename), events);
1004
+ await appendJsonlEventsBounded(path.join(parentDir, entry.name, filename), events, warnings);
688
1005
  }
689
1006
  }
690
1007
  catch {
691
1008
  // skip missing dir
692
1009
  }
693
1010
  }
694
- async function appendJsonlEvents(filePath, events) {
695
- const raw = await safeReadText(filePath);
696
- if (!raw)
697
- return;
698
- for (const line of raw.split("\n")) {
699
- const trimmed = line.trim();
1011
+ async function appendJsonlEventsBounded(filePath, events, warnings) {
1012
+ const tail = await readBoundedJsonlTail(filePath, {
1013
+ maxBytes: SNAPSHOT_EVENTS_MAX_BYTES_PER_FILE,
1014
+ maxLines: SNAPSHOT_EVENTS_MAX_LINES_PER_FILE,
1015
+ });
1016
+ let accepted = 0;
1017
+ for (const line of tail.lines) {
1018
+ const trimmed = line.text.trim();
700
1019
  if (!trimmed)
701
1020
  continue;
702
1021
  const event = parseWorkerEventLine(trimmed);
703
- if (event)
1022
+ if (event) {
704
1023
  events.push(event);
1024
+ accepted += 1;
1025
+ }
1026
+ }
1027
+ if (tail.truncated) {
1028
+ warnings.push("truncated event tail for " + path.basename(path.dirname(filePath)) + "/" + path.basename(filePath) + " to newest " + accepted + " valid events");
705
1029
  }
706
1030
  }
707
1031
  async function loadLedgerRuns(repoRoot) {
708
1032
  const runs = [];
709
- const raw = await safeReadText(path.join(getTaskPoolRoot(repoRoot), "runs.jsonl"));
710
- if (!raw)
711
- return runs;
712
- for (const line of raw.split("\n")) {
713
- const trimmed = line.trim();
1033
+ const warnings = [];
1034
+ const tail = await readBoundedJsonlTail(path.join(getTaskPoolRoot(repoRoot), "runs.jsonl"), { maxBytes: SNAPSHOT_LEDGER_MAX_BYTES, maxLines: SNAPSHOT_LEDGER_MAX_LINES });
1035
+ if (tail.truncated) {
1036
+ warnings.push("runs.jsonl projection truncated to newest " +
1037
+ SNAPSHOT_LEDGER_MAX_LINES +
1038
+ " lines / " +
1039
+ SNAPSHOT_LEDGER_MAX_BYTES +
1040
+ " bytes");
1041
+ }
1042
+ for (const line of tail.lines) {
1043
+ const trimmed = line.text.trim();
714
1044
  if (!trimmed)
715
1045
  continue;
716
1046
  const parsed = safeParseJson(trimmed);
@@ -724,6 +1054,7 @@ async function loadLedgerRuns(repoRoot) {
724
1054
  continue;
725
1055
  const failure = readObject(parsed, "failure");
726
1056
  const failureArtifacts = readObject(parsed, "failureArtifacts");
1057
+ const retryOfWorkerRunId = readString(parsed, "retryOfWorkerRunId");
727
1058
  runs.push({
728
1059
  batchRunId,
729
1060
  workerRunId,
@@ -734,8 +1065,11 @@ async function loadLedgerRuns(repoRoot) {
734
1065
  runRecordPath: readString(parsed, "runRecordPath"),
735
1066
  dagPath: readString(parsed, "dagPath"),
736
1067
  recordedAt: readString(parsed, "recordedAt"),
1068
+ ...(retryOfWorkerRunId ? { retryOfWorkerRunId } : {}),
737
1069
  failureCategory: failure ? readString(failure, "category") : undefined,
738
- recommendedFollowUp: failure ? readString(failure, "recommendedFollowUpKind") : undefined,
1070
+ recommendedFollowUp: failure
1071
+ ? readString(failure, "recommendedFollowUpKind")
1072
+ : undefined,
739
1073
  failureArtifacts: failureArtifacts
740
1074
  ? {
741
1075
  reportMarkdown: readString(failureArtifacts, "reportMarkdownArtifactPath"),
@@ -745,7 +1079,7 @@ async function loadLedgerRuns(repoRoot) {
745
1079
  : undefined,
746
1080
  });
747
1081
  }
748
- return runs;
1082
+ return { runs, warnings };
749
1083
  }
750
1084
  async function loadLedgerBatches(repoRoot) {
751
1085
  const batches = [];
@@ -761,20 +1095,26 @@ async function loadLedgerBatches(repoRoot) {
761
1095
  if (!parsed)
762
1096
  continue;
763
1097
  const summaryObj = readObject(parsed, "summary");
1098
+ let summary;
1099
+ if (summaryObj) {
1100
+ const recordErrors = readNumber(summaryObj, "recordErrors");
1101
+ const runErrors = readNumber(summaryObj, "runErrors");
1102
+ summary = {
1103
+ total: readNumber(summaryObj, "total") ?? 0,
1104
+ succeeded: readNumber(summaryObj, "succeeded") ?? 0,
1105
+ failed: readNumber(summaryObj, "failed") ?? 0,
1106
+ reused: readNumber(summaryObj, "reused") ?? 0,
1107
+ ...(recordErrors !== undefined ? { recordErrors } : {}),
1108
+ ...(runErrors !== undefined ? { runErrors } : {}),
1109
+ };
1110
+ }
764
1111
  batches.push({
765
1112
  batchRunId: readString(parsed, "batchRunId") ?? batchRunId,
766
1113
  featureId: readString(parsed, "featureId"),
767
1114
  startedAt: readString(parsed, "startedAt"),
768
1115
  finishedAt: readString(parsed, "finishedAt"),
769
1116
  status: mapBatchStatus(readString(parsed, "status") ?? "unknown"),
770
- summary: summaryObj
771
- ? {
772
- total: readNumber(summaryObj, "total") ?? 0,
773
- succeeded: readNumber(summaryObj, "succeeded") ?? 0,
774
- failed: readNumber(summaryObj, "failed") ?? 0,
775
- reused: readNumber(summaryObj, "reused") ?? 0,
776
- }
777
- : undefined,
1117
+ summary,
778
1118
  tasks: [],
779
1119
  batchRunPath,
780
1120
  });
@@ -787,34 +1127,88 @@ async function loadLedgerBatches(repoRoot) {
787
1127
  }
788
1128
  async function loadStateRecords(repoRoot) {
789
1129
  const states = [];
790
- const stateDir = path.join(getTaskPoolRoot(repoRoot), "states");
1130
+ const legacyCandidates = [];
1131
+ const warnings = [];
1132
+ // Primary path: v2 nested states via pool list API (read-only import).
791
1133
  try {
792
- const entries = await readdir(stateDir, { withFileTypes: true });
793
- for (const entry of entries) {
794
- if (!entry.isFile() || !entry.name.endsWith(".json"))
1134
+ const v2 = await listTaskPoolStates(repoRoot);
1135
+ for (const record of v2) {
1136
+ if (!record.taskId || !record.status)
795
1137
  continue;
796
- const parsed = await safeReadJson(path.join(stateDir, entry.name));
797
- if (!parsed)
1138
+ if (!record.featureId) {
1139
+ warnings.push(`v2 state for task ${record.taskId} missing featureId; excluded from composite projection`);
798
1140
  continue;
799
- const taskId = readString(parsed, "taskId") ?? entry.name.replace(/\.json$/, "");
1141
+ }
1142
+ states.push({
1143
+ taskId: record.taskId,
1144
+ featureId: record.featureId,
1145
+ status: record.status,
1146
+ workerRunId: record.workerRunId,
1147
+ lastRunRecordPath: record.lastRunRecordPath,
1148
+ updatedAt: record.updatedAt,
1149
+ failureCategory: record.failure?.category,
1150
+ recommendedFollowUp: record.failure?.recommendedFollowUpKind,
1151
+ });
1152
+ }
1153
+ }
1154
+ catch (error) {
1155
+ warnings.push(`failed to load feature-scoped task pool states: ${error instanceof Error ? error.message : String(error)}`);
1156
+ }
1157
+ // Compatibility: legacy flat states/<taskId>.json (warning + unique-only merge later).
1158
+ try {
1159
+ const legacyFiles = await listLegacyStateFiles(repoRoot);
1160
+ for (const file of legacyFiles) {
1161
+ const parsed = await safeReadJson(file.path);
1162
+ if (!parsed) {
1163
+ warnings.push(`legacy flat state file unreadable or corrupt: ${file.path}`);
1164
+ continue;
1165
+ }
1166
+ const taskId = readString(parsed, "taskId") ?? file.taskId;
800
1167
  const status = readString(parsed, "status");
801
- if (!status)
1168
+ if (!taskId || !status) {
1169
+ warnings.push(`legacy flat state missing taskId/status: ${file.path}`);
802
1170
  continue;
1171
+ }
803
1172
  const failure = readObject(parsed, "failure");
804
- states.push({
1173
+ const embeddedFeatureId = readString(parsed, "featureId");
1174
+ if (embeddedFeatureId) {
1175
+ // Rare: flat file already carries featureId — treat as owned state.
1176
+ states.push({
1177
+ taskId,
1178
+ featureId: embeddedFeatureId,
1179
+ status,
1180
+ workerRunId: readString(parsed, "workerRunId"),
1181
+ lastRunRecordPath: readString(parsed, "lastRunRecordPath"),
1182
+ updatedAt: readString(parsed, "updatedAt"),
1183
+ failureCategory: failure
1184
+ ? readString(failure, "category")
1185
+ : undefined,
1186
+ recommendedFollowUp: failure
1187
+ ? readString(failure, "recommendedFollowUpKind")
1188
+ : undefined,
1189
+ legacyFlat: true,
1190
+ });
1191
+ warnings.push(`legacy flat state for task ${taskId} carries featureId ${embeddedFeatureId}`);
1192
+ continue;
1193
+ }
1194
+ legacyCandidates.push({
805
1195
  taskId,
806
1196
  status,
807
1197
  workerRunId: readString(parsed, "workerRunId"),
808
1198
  lastRunRecordPath: readString(parsed, "lastRunRecordPath"),
1199
+ updatedAt: readString(parsed, "updatedAt"),
809
1200
  failureCategory: failure ? readString(failure, "category") : undefined,
810
- recommendedFollowUp: failure ? readString(failure, "recommendedFollowUpKind") : undefined,
1201
+ recommendedFollowUp: failure
1202
+ ? readString(failure, "recommendedFollowUpKind")
1203
+ : undefined,
1204
+ legacyFlat: true,
811
1205
  });
812
1206
  }
813
1207
  }
814
- catch {
815
- // skip
1208
+ catch (error) {
1209
+ warnings.push(`failed to scan legacy flat states: ${error instanceof Error ? error.message : String(error)}`);
816
1210
  }
817
- return states;
1211
+ return { states, legacyCandidates, warnings };
818
1212
  }
819
1213
  async function loadFailureHandoffs(repoRoot) {
820
1214
  const handoffs = [];
@@ -858,7 +1252,9 @@ function mergeDagRun(existing, incoming) {
858
1252
  return incoming;
859
1253
  const startedAt = incoming.startedAt ?? existing.startedAt;
860
1254
  const finishedAt = incoming.finishedAt ?? existing.finishedAt;
861
- const ranks = incoming.ranks && incoming.ranks.length > 0 ? incoming.ranks : existing.ranks;
1255
+ const ranks = incoming.ranks && incoming.ranks.length > 0
1256
+ ? incoming.ranks
1257
+ : existing.ranks;
862
1258
  const durationMs = incoming.durationMs ??
863
1259
  existing.durationMs ??
864
1260
  computeDurationMs(startedAt, finishedAt);
@@ -877,7 +1273,9 @@ function mergeDagRun(existing, incoming) {
877
1273
  ...(ranks && ranks.length > 0 ? { ranks } : {}),
878
1274
  nodes: mergeDagNodes(existing.nodes, incoming.nodes),
879
1275
  edges: incoming.edges.length > 0 ? incoming.edges : existing.edges,
880
- ...(incoming.dagPath ?? existing.dagPath ? { dagPath: incoming.dagPath ?? existing.dagPath } : {}),
1276
+ ...((incoming.dagPath ?? existing.dagPath)
1277
+ ? { dagPath: incoming.dagPath ?? existing.dagPath }
1278
+ : {}),
881
1279
  };
882
1280
  }
883
1281
  function mergeDagNodes(existing, incoming) {
@@ -921,7 +1319,9 @@ function isFailedNodeStatus(status) {
921
1319
  if (!status)
922
1320
  return false;
923
1321
  const normalized = status.toLowerCase();
924
- return normalized === "error" || normalized === "failed" || normalized === "partial_failed";
1322
+ return (normalized === "error" ||
1323
+ normalized === "failed" ||
1324
+ normalized === "partial_failed");
925
1325
  }
926
1326
  function nodeOutputPreview(node) {
927
1327
  const stdout = readString(node, "stdout") ?? readString(node, "assistantText");
@@ -997,13 +1397,19 @@ async function loadDagEventFiles(repoRoot) {
997
1397
  const outputPreview = readString(parsed, "outputPreview");
998
1398
  nodeMap.set(nodeId, {
999
1399
  nodeId,
1000
- ...(rank ?? prev.rank ? { rank: rank ?? prev.rank } : {}),
1001
- ...(executor ?? prev.executor ? { executor: executor ?? prev.executor } : {}),
1002
- ...(model ?? prev.model ? { model: model ?? prev.model } : {}),
1003
- ...(label ?? prev.label ? { label: label ?? prev.label } : {}),
1004
- ...(status ?? prev.status ? { status: status ?? prev.status } : {}),
1005
- ...(durationMs ?? prev.durationMs ? { durationMs: durationMs ?? prev.durationMs } : {}),
1006
- ...(outputPreview ?? prev.outputPreview ? { outputPreview: outputPreview ?? prev.outputPreview } : {}),
1400
+ ...((rank ?? prev.rank) ? { rank: rank ?? prev.rank } : {}),
1401
+ ...((executor ?? prev.executor)
1402
+ ? { executor: executor ?? prev.executor }
1403
+ : {}),
1404
+ ...((model ?? prev.model) ? { model: model ?? prev.model } : {}),
1405
+ ...((label ?? prev.label) ? { label: label ?? prev.label } : {}),
1406
+ ...((status ?? prev.status) ? { status: status ?? prev.status } : {}),
1407
+ ...((durationMs ?? prev.durationMs)
1408
+ ? { durationMs: durationMs ?? prev.durationMs }
1409
+ : {}),
1410
+ ...((outputPreview ?? prev.outputPreview)
1411
+ ? { outputPreview: outputPreview ?? prev.outputPreview }
1412
+ : {}),
1007
1413
  });
1008
1414
  byId.set(dagRunId, nodeMap);
1009
1415
  }
@@ -1059,7 +1465,9 @@ async function parseDagStateFile(statePath, now) {
1059
1465
  return undefined;
1060
1466
  const runDir = path.dirname(statePath);
1061
1467
  const lifecycleName = path.basename(path.dirname(runDir));
1062
- const lifecycle = lifecycleName === "active" || lifecycleName === "paused" || lifecycleName === "completed"
1468
+ const lifecycle = lifecycleName === "active" ||
1469
+ lifecycleName === "paused" ||
1470
+ lifecycleName === "completed"
1063
1471
  ? lifecycleName
1064
1472
  : undefined;
1065
1473
  const dagRunId = readString(parsed, "runId") ?? path.basename(runDir);
@@ -1077,14 +1485,29 @@ async function parseDagStateFile(statePath, now) {
1077
1485
  const state = parsed;
1078
1486
  const liveness = assessDagRunLiveness({ state, now });
1079
1487
  const effectiveStatus = lifecycle
1080
- ? deriveDagRunEffectiveStatus({ lifecycle, state, liveness: liveness.status })
1488
+ ? deriveDagRunEffectiveStatus({
1489
+ lifecycle,
1490
+ state,
1491
+ liveness: liveness.status,
1492
+ })
1081
1493
  : "unknown";
1082
1494
  const stateConsistent = lifecycle === undefined
1083
1495
  ? false
1084
- : !((lifecycle === "paused" && state.status !== "paused")
1085
- || (lifecycle === "completed" && !["finished", "failed", "partial_failed", "superseded", "abandoned"].includes(state.status)));
1496
+ : !((lifecycle === "paused" && state.status !== "paused") ||
1497
+ (lifecycle === "completed" &&
1498
+ ![
1499
+ "finished",
1500
+ "failed",
1501
+ "partial_failed",
1502
+ "superseded",
1503
+ "abandoned",
1504
+ ].includes(state.status)));
1086
1505
  const recoveryEligibility = lifecycle
1087
- ? assessDagRunRecoveryEligibility({ lifecycle, state, liveness: liveness.status })
1506
+ ? assessDagRunRecoveryEligibility({
1507
+ lifecycle,
1508
+ state,
1509
+ liveness: liveness.status,
1510
+ })
1088
1511
  : undefined;
1089
1512
  return {
1090
1513
  dagRunId,
@@ -1122,7 +1545,9 @@ async function parseDagEdges(runPath, nodes) {
1122
1545
  if (!taskId || !knownNodeIds.has(taskId))
1123
1546
  continue;
1124
1547
  const dependencies = readStringArray(task, "depends_on");
1125
- const compatibleDependencies = dependencies.length > 0 ? dependencies : readStringArray(task, "dependsOn");
1548
+ const compatibleDependencies = dependencies.length > 0
1549
+ ? dependencies
1550
+ : readStringArray(task, "dependsOn");
1126
1551
  for (const dependencyId of compatibleDependencies) {
1127
1552
  if (dependencyId === taskId || !knownNodeIds.has(dependencyId))
1128
1553
  continue;
@@ -1169,7 +1594,10 @@ async function loadDagNodeModels(runPath) {
1169
1594
  const legacy = raw;
1170
1595
  const tasks = legacy.tasks;
1171
1596
  const executorModels = legacy.executorModels;
1172
- if (!Array.isArray(tasks) || typeof executorModels !== "object" || executorModels === null || Array.isArray(executorModels)) {
1597
+ if (!Array.isArray(tasks) ||
1598
+ typeof executorModels !== "object" ||
1599
+ executorModels === null ||
1600
+ Array.isArray(executorModels)) {
1173
1601
  return models;
1174
1602
  }
1175
1603
  for (const task of tasks) {
@@ -1194,7 +1622,9 @@ async function loadDagNodeModels(runPath) {
1194
1622
  function parseDagNodes(parsed, rankByNode, modelByNode) {
1195
1623
  const nodes = [];
1196
1624
  const nodesValue = parsed.nodes;
1197
- if (nodesValue && typeof nodesValue === "object" && !Array.isArray(nodesValue)) {
1625
+ if (nodesValue &&
1626
+ typeof nodesValue === "object" &&
1627
+ !Array.isArray(nodesValue)) {
1198
1628
  for (const [nodeId, nodeVal] of Object.entries(nodesValue)) {
1199
1629
  if (!nodeVal || typeof nodeVal !== "object" || Array.isArray(nodeVal))
1200
1630
  continue;
@@ -1261,7 +1691,9 @@ async function safeReadJson(filePath) {
1261
1691
  function safeParseJson(raw) {
1262
1692
  try {
1263
1693
  const parsed = JSON.parse(raw);
1264
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1694
+ if (parsed === null ||
1695
+ typeof parsed !== "object" ||
1696
+ Array.isArray(parsed)) {
1265
1697
  return undefined;
1266
1698
  }
1267
1699
  return parsed;
@@ -1304,7 +1736,9 @@ function readNumber(value, key) {
1304
1736
  if (!value || typeof value !== "object")
1305
1737
  return undefined;
1306
1738
  const child = value[key];
1307
- return typeof child === "number" && Number.isFinite(child) ? child : undefined;
1739
+ return typeof child === "number" && Number.isFinite(child)
1740
+ ? child
1741
+ : undefined;
1308
1742
  }
1309
1743
  function readStringMatrix(value, key) {
1310
1744
  if (!value || typeof value !== "object")