@tea-agent/loop-agent 0.12.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 (79) hide show
  1. package/CHANGELOG.md +50 -2
  2. package/README.md +30 -2
  3. package/dist/application/dag/generate-task-dag.js +30 -0
  4. package/dist/cli/command-definitions.js +10 -3
  5. package/dist/commands/knowledge.js +129 -31
  6. package/dist/governance/manifest-types.js +3 -0
  7. package/dist/task/config-types.js +5 -1
  8. package/dist/worker/cli.js +96 -1
  9. package/dist/worker/delivery/package.js +3 -3
  10. package/dist/worker/feature/decision-loader.js +37 -6
  11. package/dist/worker/feature/next-action.js +10 -2
  12. package/dist/worker/feature/ready-plan-projection.js +81 -0
  13. package/dist/worker/feature/reducer.js +2 -1
  14. package/dist/worker/feature/review.js +19 -2
  15. package/dist/worker/feature/run.js +27 -2
  16. package/dist/worker/follow-up/approve.js +5 -2
  17. package/dist/worker/follow-up/factory.js +1 -1
  18. package/dist/worker/observability/read-model.js +246 -41
  19. package/dist/worker/observe/routes.js +158 -15
  20. package/dist/worker/observe/spec-evidence.js +281 -0
  21. package/dist/worker/observe/static/api.js +19 -0
  22. package/dist/worker/observe/static/app.js +2 -2
  23. package/dist/worker/observe/static/relations.js +17 -12
  24. package/dist/worker/observe/static/router.js +8 -0
  25. package/dist/worker/observe/static/styles.css +12 -0
  26. package/dist/worker/observe/static/views/batch.js +3 -2
  27. package/dist/worker/observe/static/views/dag-inspector.js +123 -4
  28. package/dist/worker/observe/static/views/dashboard.js +8 -5
  29. package/dist/worker/observe/static/views/feature.js +43 -4
  30. package/dist/worker/observe/static/views/pool.js +5 -2
  31. package/dist/worker/observe/static/views/run.js +1 -1
  32. package/dist/worker/observe/static/views/task.js +69 -15
  33. package/dist/worker/pool/doctor.js +165 -0
  34. package/dist/worker/pool/migrate-state.js +303 -0
  35. package/dist/worker/pool/run-store.js +205 -17
  36. package/dist/worker/pool/types.js +17 -1
  37. package/dist/worker/pool/validation.js +100 -15
  38. package/dist/worker/report/morning-report.js +12 -2
  39. package/dist/worker/runner/run-ready.js +41 -26
  40. package/dist/worker/task-graph/ready-planner.js +136 -0
  41. package/dist/workflows/dag/convergence/controller.js +16 -8
  42. package/dist/workflows/dag/failure-routing.js +12 -1
  43. package/dist/workflows/dag/init-hybrid.js +837 -8
  44. package/dist/workflows/dag/types.js +1 -0
  45. package/docs/README.md +1 -1
  46. package/docs/agent-dag-recovery-playbook.md +9 -0
  47. package/docs/architecture/evolution.md +4 -3
  48. package/docs/architecture/facts-and-state.md +14 -1
  49. package/docs/architecture/worker-and-feature.md +6 -2
  50. package/docs/decisions/README.md +3 -0
  51. package/docs/design/README.md +8 -0
  52. package/docs/exec-plans/active/README.md +2 -0
  53. package/docs/exec-plans/completed/README.md +3 -2
  54. package/docs/feature-workflow.md +80 -2
  55. package/docs/loop-agent-harness.md +15 -4
  56. package/docs/progress/README.md +4 -0
  57. package/docs/reports/README.md +6 -0
  58. package/docs/templates/backend-test-dag.json +12 -0
  59. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
  60. package/docs/templates/knowledge-sync-dag.json +177 -0
  61. package/docs/templates/knowledge-sync-draft.schema.json +71 -0
  62. package/docs/verification-matrix.md +2 -1
  63. package/package.json +8 -2
  64. package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
  65. package/scripts/kb-graph-incremental-prepare.mjs +372 -0
  66. package/scripts/kb-graph-incremental-prepare.sh +5 -0
  67. package/scripts/kb-graph-materialize.mjs +105 -0
  68. package/scripts/kb-graph-materialize.sh +4 -0
  69. package/scripts/kb-graph-promote.mjs +153 -0
  70. package/scripts/kb-graph-promote.sh +4 -0
  71. package/scripts/kb-query.mjs +554 -0
  72. package/scripts/kb-query.sh +5 -0
  73. package/skills/agent-worker/SKILL.md +3 -1
  74. package/skills/agent-worker/references/agent-worker-operator.md +18 -1
  75. package/skills/frontend-design-review/SKILL.md +26 -24
  76. package/skills/frontend-implementation/SKILL.md +29 -26
  77. package/skills/frontend-implementation/references/node-contracts.md +50 -19
  78. package/skills/frontend-review/SKILL.md +1 -1
  79. package/skills/loop-agent/references/command-reference.md +1 -0
@@ -4,10 +4,41 @@ import path from "node:path";
4
4
  import { truncateUtf8Preview } from "../../shared/preview.js";
5
5
  import { parseWorkerEventLine } from "./events.js";
6
6
  import { readBoundedJsonlTail } from "./event-history.js";
7
- import { getTaskPoolRoot } from "../pool/run-store.js";
7
+ import { getTaskPoolRoot, listLegacyStateFiles, listTaskPoolStates, } from "../pool/run-store.js";
8
8
  import { assessDagRunLiveness, assessDagRunRecoveryEligibility, deriveDagRunEffectiveStatus, } from "../../workflows/dag/lifecycle.js";
9
9
  import { parseDagSpec, resolveModelForTask, } from "../../workflows/dag/types.js";
10
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
+ }
11
42
  export const TASK_RUN_HISTORY_DEFAULT_LIMIT = 20;
12
43
  export const TASK_RUN_HISTORY_MAX_LIMIT = 100;
13
44
  const ACTIVE_TASK_STATUSES = new Set(["running", "pending"]);
@@ -19,7 +50,7 @@ export async function buildGlobalSnapshot(options) {
19
50
  const staleThresholdMs = options.staleThresholdMs ?? 90_000;
20
51
  const quietThresholdMs = options.quietThresholdMs ?? 60_000;
21
52
  const taskPoolPresent = existsSync(getTaskPoolRoot(repoRoot));
22
- const [eventsLoad, ledgerLoad, ledgerBatches, states, handoffs, dagRuns] = await Promise.all([
53
+ const [eventsLoad, ledgerLoad, ledgerBatches, statesLoad, handoffs, dagRuns,] = await Promise.all([
23
54
  loadObservabilityEvents(repoRoot),
24
55
  loadLedgerRuns(repoRoot),
25
56
  loadLedgerBatches(repoRoot),
@@ -29,23 +60,30 @@ export async function buildGlobalSnapshot(options) {
29
60
  ]);
30
61
  const events = eventsLoad.events;
31
62
  const ledgerRuns = ledgerLoad.runs;
63
+ const identityWarnings = [];
32
64
  const taskMap = new Map();
33
- for (const state of states) {
34
- mergeStateRecord(taskMap, state);
35
- }
65
+ // Ledger first so featureId-bearing rows seed composite identity before states/events.
36
66
  for (const run of ledgerRuns) {
37
- 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);
38
75
  }
39
- mergeEvents(taskMap, events);
76
+ const workerRunFeatureIndex = buildWorkerRunFeatureIndex(ledgerRuns, taskMap);
77
+ mergeEvents(taskMap, events, workerRunFeatureIndex, identityWarnings);
40
78
  mergeHandoffs(taskMap, handoffs);
41
79
  applyStaleDetection(taskMap, now(), staleThresholdMs, quietThresholdMs);
42
- const historicalRunMap = buildHistoricalRunMap(ledgerRuns, events);
80
+ const historicalRunMap = buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex, identityWarnings);
43
81
  mergeHandoffs(historicalRunMap, handoffs);
44
82
  applyStaleDetection(historicalRunMap, now(), staleThresholdMs, quietThresholdMs);
45
83
  const tasks = [...taskMap.values()]
46
84
  .map(toPublicTaskSummary)
47
85
  .map((task) => normalizeTaskArtifactRefs(task, repoRoot))
48
- .sort((a, b) => a.taskId.localeCompare(b.taskId));
86
+ .sort(compareTaskIdentity);
49
87
  const batches = buildBatches(ledgerBatches, events, historicalRunMap).map((batch) => ({
50
88
  ...batch,
51
89
  tasks: batch.tasks.map((task) => normalizeTaskArtifactRefs(task, repoRoot)),
@@ -55,6 +93,8 @@ export async function buildGlobalSnapshot(options) {
55
93
  const projectionWarnings = [
56
94
  ...(eventsLoad.warnings ?? []),
57
95
  ...(ledgerLoad.warnings ?? []),
96
+ ...(statesLoad.warnings ?? []),
97
+ ...identityWarnings,
58
98
  ...(featureLoad.projectionWarnings ?? []),
59
99
  ];
60
100
  return {
@@ -94,9 +134,10 @@ export async function buildGlobalSnapshot(options) {
94
134
  }
95
135
  /**
96
136
  * Bounded Task run history from Task Pool ledger facts (newest first).
137
+ * Filtered by composite `{featureId, taskId}`.
97
138
  * `before` is an exclusive upper bound on the sort key (`recordedAt` or empty).
98
139
  */
99
- export async function listTaskRunHistory(repoRoot, taskId, options = {}) {
140
+ export async function listTaskRunHistory(repoRoot, featureId, taskId, options = {}) {
100
141
  const limit = clampTaskRunHistoryLimit(options.limit);
101
142
  const before = decodeTaskRunHistoryCursor(options.before);
102
143
  const [ledgerLoad, eventsLoad] = await Promise.all([
@@ -105,15 +146,19 @@ export async function listTaskRunHistory(repoRoot, taskId, options = {}) {
105
146
  ]);
106
147
  const ledgerRuns = ledgerLoad.runs;
107
148
  const events = eventsLoad.events;
108
- const historical = buildHistoricalRunMap(ledgerRuns, events);
149
+ const workerRunFeatureIndex = buildWorkerRunFeatureIndex(ledgerRuns);
150
+ const historical = buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex);
109
151
  const items = [];
110
152
  for (const run of ledgerRuns) {
111
153
  if (run.taskId !== taskId)
112
154
  continue;
155
+ if ((run.featureId ?? "") !== featureId)
156
+ continue;
113
157
  const enriched = historical.get(run.workerRunId);
114
158
  items.push({
115
159
  workerRunId: run.workerRunId,
116
160
  taskId: run.taskId,
161
+ featureId: run.featureId ?? featureId,
117
162
  status: enriched?.status ?? mapRunRecordStatus(run.status),
118
163
  startedAt: enriched?.startedAt,
119
164
  finishedAt: enriched?.finishedAt ?? run.recordedAt,
@@ -142,6 +187,7 @@ export async function listTaskRunHistory(repoRoot, taskId, options = {}) {
142
187
  ? encodeTaskRunHistoryCursor(page[page.length - 1])
143
188
  : null;
144
189
  return {
190
+ featureId,
145
191
  taskId,
146
192
  runs: page,
147
193
  limit,
@@ -226,13 +272,21 @@ function toPublicTaskSummary(task) {
226
272
  const { timeoutMs: _to, commandStartedAt: _cs, ...rest } = task;
227
273
  return rest;
228
274
  }
229
- function mergeStateRecord(taskMap, state) {
230
- const existing = taskMap.get(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) ?? {
231
283
  taskId: state.taskId,
284
+ featureId,
232
285
  status: "unknown",
233
286
  };
234
- taskMap.set(state.taskId, {
287
+ taskMap.set(key, {
235
288
  ...existing,
289
+ featureId,
236
290
  status: existing.status !== "unknown"
237
291
  ? existing.status
238
292
  : mapStateStatus(state.status),
@@ -245,9 +299,35 @@ function mergeStateRecord(taskMap, state) {
245
299
  recommendedFollowUp: existing.recommendedFollowUp ?? state.recommendedFollowUp,
246
300
  });
247
301
  }
248
- function mergeLedgerRun(taskMap, run) {
249
- const existing = taskMap.get(run.taskId) ?? {
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) ?? {
250
329
  taskId: run.taskId,
330
+ featureId,
251
331
  status: "unknown",
252
332
  };
253
333
  const sameRun = existing.workerRunId === run.workerRunId;
@@ -256,17 +336,17 @@ function mergeLedgerRun(taskMap, run) {
256
336
  dagPath: run.dagPath,
257
337
  ...run.failureArtifacts,
258
338
  };
259
- taskMap.set(run.taskId, {
339
+ taskMap.set(key, {
260
340
  ...(sameRun
261
341
  ? existing
262
342
  : {
263
343
  taskId: run.taskId,
344
+ featureId,
264
345
  status: "unknown",
265
- featureId: existing.featureId,
266
346
  harnessTaskId: existing.harnessTaskId,
267
347
  }),
268
348
  batchRunId: run.batchRunId,
269
- featureId: run.featureId ?? existing.featureId,
349
+ featureId,
270
350
  status: mapRunRecordStatus(run.status),
271
351
  workerRunId: run.workerRunId,
272
352
  harnessTaskId: run.harnessTaskId ?? existing.harnessTaskId,
@@ -277,21 +357,82 @@ function mergeLedgerRun(taskMap, run) {
277
357
  artifactRefs: mergeArtifactRefs(sameRun ? existing.artifactRefs : undefined, runArtifactRefs),
278
358
  });
279
359
  }
280
- 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) {
281
395
  const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at));
282
396
  for (const event of sorted) {
283
397
  if (event.taskId) {
284
- applyTaskEvent(taskMap, event);
398
+ applyTaskEvent(taskMap, event, workerRunFeatureIndex, warnings);
285
399
  }
286
400
  }
287
401
  }
288
- function applyTaskEvent(taskMap, event) {
402
+ function applyTaskEvent(taskMap, event, workerRunFeatureIndex, warnings) {
289
403
  const taskId = event.taskId;
290
- const existing = taskMap.get(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) ?? {
291
409
  taskId,
410
+ featureId,
292
411
  status: "unknown",
293
412
  };
294
- taskMap.set(taskId, applyEventToSummary(existing, event));
413
+ taskMap.set(key, applyEventToSummary(existing, event));
414
+ }
415
+ function resolveEventFeatureId(event, taskMap, workerRunFeatureIndex, warnings) {
416
+ const taskId = event.taskId;
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;
295
436
  }
296
437
  function applyEventToSummary(existing, event) {
297
438
  const updated = {
@@ -342,9 +483,10 @@ function applyEventToSummary(existing, event) {
342
483
  }
343
484
  return updated;
344
485
  }
345
- function buildHistoricalRunMap(ledgerRuns, events) {
486
+ function buildHistoricalRunMap(ledgerRuns, events, workerRunFeatureIndex = new Map(), warnings) {
346
487
  const runMap = new Map();
347
488
  for (const run of ledgerRuns) {
489
+ // History map is keyed by workerRunId (run-scoped); featureId still required when present.
348
490
  runMap.set(run.workerRunId, {
349
491
  taskId: run.taskId,
350
492
  batchRunId: run.batchRunId,
@@ -365,12 +507,23 @@ function buildHistoricalRunMap(ledgerRuns, events) {
365
507
  for (const event of [...events].sort((a, b) => a.at.localeCompare(b.at))) {
366
508
  if (!event.workerRunId || !event.taskId)
367
509
  continue;
368
- 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({
369
522
  taskId: event.taskId,
523
+ featureId,
370
524
  workerRunId: event.workerRunId,
371
525
  status: "unknown",
372
- };
373
- runMap.set(event.workerRunId, applyEventToSummary(existing, event));
526
+ }, event));
374
527
  }
375
528
  return runMap;
376
529
  }
@@ -512,7 +665,7 @@ function buildBatches(ledgerBatches, events, taskMap) {
512
665
  batch.tasks.push(toPublicTaskSummary(task));
513
666
  }
514
667
  for (const batch of batchMap.values()) {
515
- batch.tasks.sort((a, b) => a.taskId.localeCompare(b.taskId));
668
+ batch.tasks.sort(compareTaskIdentity);
516
669
  if (batch.summary === undefined && batch.tasks.length > 0) {
517
670
  batch.summary = summarizeTasks(batch.tasks);
518
671
  }
@@ -784,6 +937,7 @@ function mapStateStatus(status) {
784
937
  case "Running":
785
938
  return "running";
786
939
  case "Queued":
940
+ case "Ready":
787
941
  return "pending";
788
942
  case "Done":
789
943
  return "succeeded";
@@ -973,21 +1127,71 @@ async function loadLedgerBatches(repoRoot) {
973
1127
  }
974
1128
  async function loadStateRecords(repoRoot) {
975
1129
  const states = [];
976
- 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).
977
1133
  try {
978
- const entries = await readdir(stateDir, { withFileTypes: true });
979
- for (const entry of entries) {
980
- 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)
981
1137
  continue;
982
- const parsed = await safeReadJson(path.join(stateDir, entry.name));
983
- if (!parsed)
1138
+ if (!record.featureId) {
1139
+ warnings.push(`v2 state for task ${record.taskId} missing featureId; excluded from composite projection`);
984
1140
  continue;
985
- 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;
986
1167
  const status = readString(parsed, "status");
987
- if (!status)
1168
+ if (!taskId || !status) {
1169
+ warnings.push(`legacy flat state missing taskId/status: ${file.path}`);
988
1170
  continue;
1171
+ }
989
1172
  const failure = readObject(parsed, "failure");
990
- 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({
991
1195
  taskId,
992
1196
  status,
993
1197
  workerRunId: readString(parsed, "workerRunId"),
@@ -997,13 +1201,14 @@ async function loadStateRecords(repoRoot) {
997
1201
  recommendedFollowUp: failure
998
1202
  ? readString(failure, "recommendedFollowUpKind")
999
1203
  : undefined,
1204
+ legacyFlat: true,
1000
1205
  });
1001
1206
  }
1002
1207
  }
1003
- catch {
1004
- // skip
1208
+ catch (error) {
1209
+ warnings.push(`failed to scan legacy flat states: ${error instanceof Error ? error.message : String(error)}`);
1005
1210
  }
1006
- return states;
1211
+ return { states, legacyCandidates, warnings };
1007
1212
  }
1008
1213
  async function loadFailureHandoffs(repoRoot) {
1009
1214
  const handoffs = [];