@tea-agent/loop-agent 0.12.0 → 0.13.0-beta.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.
- package/AGENTS.md +155 -153
- package/CHANGELOG.md +338 -265
- package/README.md +345 -298
- package/bin/agent-worker.js +22 -22
- package/bin/loop-agent.js +21 -21
- package/dist/application/dag/generate-task-dag.js +28 -28
- package/dist/application/evaluation/candidate-hash.js +75 -0
- package/dist/application/evaluation/candidate.js +52 -0
- package/dist/application/evaluation/replay.js +289 -0
- package/dist/application/evaluation/types.js +130 -0
- package/dist/cli/command-definitions.js +27 -7
- package/dist/cli/program.js +8 -4
- package/dist/commands/cursor-prompt.js +6 -6
- package/dist/commands/eval.js +235 -0
- package/dist/commands/init.js +544 -506
- package/dist/commands/knowledge.js +129 -31
- package/dist/commands/loop-benchmark.js +11 -11
- package/dist/commands/pi-reuse-benchmark.js +16 -16
- package/dist/executors/pi-sdk-executor.js +38 -24
- package/dist/executors/shell-executor.js +34 -2
- package/dist/executors/shell-presets.js +20 -0
- package/dist/executors/shell-verification.js +7 -0
- package/dist/governance/manifest-types.js +4 -0
- package/dist/infrastructure/evaluation/candidate-store.js +435 -0
- package/dist/infrastructure/evaluation/store.js +40 -0
- package/dist/sidecars/cursor-prompt/executor.js +1 -1
- package/dist/task/config-types.js +28 -1
- package/dist/task/runtime.js +27 -27
- package/dist/worker/cli.js +96 -1
- package/dist/worker/delivery/package.js +3 -3
- package/dist/worker/feature/decision-loader.js +37 -6
- package/dist/worker/feature/next-action.js +10 -2
- package/dist/worker/feature/ready-plan-projection.js +81 -0
- package/dist/worker/feature/reducer.js +2 -1
- package/dist/worker/feature/review.js +19 -2
- package/dist/worker/feature/run.js +27 -2
- package/dist/worker/follow-up/approve.js +5 -2
- package/dist/worker/follow-up/factory.js +1 -1
- package/dist/worker/observability/read-model.js +246 -41
- package/dist/worker/observe/routes.js +173 -15
- package/dist/worker/observe/spec-evidence.js +281 -0
- package/dist/worker/observe/static/api.js +46 -27
- package/dist/worker/observe/static/app.js +150 -150
- package/dist/worker/observe/static/constants.js +148 -148
- package/dist/worker/observe/static/copy.js +67 -67
- package/dist/worker/observe/static/dag-helpers.js +172 -172
- package/dist/worker/observe/static/dag-layout.d.ts +31 -31
- package/dist/worker/observe/static/dag-layout.js +83 -83
- package/dist/worker/observe/static/dag-model.js +72 -72
- package/dist/worker/observe/static/dom.js +61 -61
- package/dist/worker/observe/static/format-pool.js +67 -67
- package/dist/worker/observe/static/format.js +292 -292
- package/dist/worker/observe/static/index.html +308 -308
- package/dist/worker/observe/static/kpi.js +94 -94
- package/dist/worker/observe/static/relations.js +133 -128
- package/dist/worker/observe/static/router.js +93 -85
- package/dist/worker/observe/static/run-processing.js +148 -148
- package/dist/worker/observe/static/shell-chrome.js +68 -68
- package/dist/worker/observe/static/state.js +253 -253
- package/dist/worker/observe/static/styles.css +1902 -1890
- package/dist/worker/observe/static/views/batch.js +227 -226
- package/dist/worker/observe/static/views/dag-graph.js +172 -172
- package/dist/worker/observe/static/views/dag-inspector.js +607 -477
- package/dist/worker/observe/static/views/dag.js +362 -362
- package/dist/worker/observe/static/views/dashboard.js +445 -442
- package/dist/worker/observe/static/views/failures.js +143 -143
- package/dist/worker/observe/static/views/feature.js +492 -453
- package/dist/worker/observe/static/views/pool.js +350 -347
- package/dist/worker/observe/static/views/run.js +453 -453
- package/dist/worker/observe/static/views/session-timeline.js +205 -205
- package/dist/worker/observe/static/views/shell.js +7 -7
- package/dist/worker/observe/static/views/task.js +314 -260
- package/dist/worker/observe/static/views/timeline.js +163 -163
- package/dist/worker/pool/doctor.js +165 -0
- package/dist/worker/pool/migrate-state.js +303 -0
- package/dist/worker/pool/run-store.js +205 -17
- package/dist/worker/pool/types.js +17 -1
- package/dist/worker/pool/validation.js +100 -15
- package/dist/worker/report/morning-report.js +12 -2
- package/dist/worker/runner/run-ready.js +41 -26
- package/dist/worker/task-graph/ready-planner.js +136 -0
- package/dist/workflows/dag/backend-test-analysis-contract.js +120 -0
- package/dist/workflows/dag/canvas-observer.js +275 -275
- package/dist/workflows/dag/convergence/controller.js +16 -8
- package/dist/workflows/dag/dynamic-runtime/map.js +90 -2
- package/dist/workflows/dag/failure-routing.js +12 -1
- package/dist/workflows/dag/init-hybrid.js +2404 -360
- package/dist/workflows/dag/node-execution.js +9 -0
- package/dist/workflows/dag/prompt.js +9 -0
- package/dist/workflows/dag/report.js +35 -1
- package/dist/workflows/dag/runner.js +28 -2
- package/dist/workflows/dag/task-demand-routing.js +383 -0
- package/dist/workflows/dag/types.js +51 -13
- package/dist/workflows/dag/upstream-artifacts.js +1 -0
- package/dist/workflows/dag/validate.js +59 -1
- package/docs/README.md +106 -104
- package/docs/agent-dag-recovery-playbook.md +195 -184
- package/docs/agent-dag-runner.md +67 -67
- package/docs/architecture/README.md +26 -26
- package/docs/architecture/dag-execution.md +140 -140
- package/docs/architecture/evolution.md +54 -53
- package/docs/architecture/facts-and-state.md +71 -58
- package/docs/architecture/runtime-boundaries.md +191 -191
- package/docs/architecture/system-overview.md +93 -93
- package/docs/architecture/worker-and-feature.md +85 -81
- package/docs/cursor-prompt-sidecar.md +36 -36
- package/docs/decisions/README.md +18 -15
- package/docs/design/README.md +167 -77
- package/docs/development-principles.md +73 -73
- package/docs/exec-plans/README.md +6 -6
- package/docs/exec-plans/active/README.md +15 -9
- package/docs/exec-plans/completed/README.md +85 -73
- package/docs/feature-workflow.md +389 -261
- package/docs/harness-methodology-debugging.md +153 -153
- package/docs/harness-methodology-tdd.md +130 -130
- package/docs/harness-methodology-verification.md +27 -27
- package/docs/init-surface.manifest.json +289 -280
- package/docs/loop-agent-harness.md +142 -130
- package/docs/production-readiness.md +96 -96
- package/docs/progress/README.md +64 -54
- package/docs/reports/README.md +117 -94
- package/docs/skills/README.md +7 -7
- package/docs/skills/vetted-skill-registry.md +29 -27
- package/docs/templates/adr.md +60 -60
- package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
- package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
- package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
- package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
- package/docs/templates/agent-dag-report.schema.json +473 -473
- package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
- package/docs/templates/agent-dag.base.json +190 -190
- package/docs/templates/agent-dag.final-verification.json +185 -185
- package/docs/templates/agent-dag.schema.json +411 -383
- package/docs/templates/agent-dag.supervised-implementation.json +501 -501
- package/docs/templates/backend-test-analysis.schema.json +44 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +202 -139
- package/docs/templates/backend-test-dag.json +311 -276
- package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -125
- package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -81
- package/docs/templates/exec-plan.md +64 -64
- package/docs/templates/feature-spec.md +53 -53
- package/docs/templates/frontend-design-contract.md +42 -33
- package/docs/templates/frontend-task-constraints.md +35 -25
- package/docs/templates/frontend-task-requirement.md +70 -61
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +5 -0
- package/docs/templates/frontend-test-dag.json +23 -0
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +3 -0
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +3 -0
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +3 -0
- package/docs/templates/frontend-test-dag.review-execution.prompt.md +3 -0
- package/docs/templates/harness.schema.json +221 -221
- package/docs/templates/hybrid-dag.json +188 -188
- package/docs/templates/init-evolution-review.md +35 -35
- package/docs/templates/interactive-ui-round2-experiment.md +66 -66
- package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
- package/docs/templates/knowledge-sync-dag.json +178 -0
- package/docs/templates/knowledge-sync-draft.schema.json +71 -0
- package/docs/templates/product-line/AGENTS.md +8 -8
- package/docs/templates/product-line/README.md +9 -9
- package/docs/templates/product-line/acceptance.yaml +14 -14
- package/docs/templates/product-line/closeout.yaml +9 -9
- package/docs/templates/product-line/design.md +13 -13
- package/docs/templates/product-line/links.md +10 -10
- package/docs/templates/product-line/requirement.md +17 -17
- package/docs/templates/product-line/task-graph.yaml +15 -15
- package/docs/templates/product-line/task.yaml +64 -64
- package/docs/templates/product-line/test-plan.md +7 -7
- package/docs/templates/production-readiness-checklist.md +57 -57
- package/docs/templates/progress-log.md +17 -17
- package/docs/templates/project-start-checklist.md +9 -9
- package/docs/templates/qa-report.md +48 -48
- package/docs/templates/sprint-contract.md +29 -29
- package/docs/templates/worker-dogfood-evidence.md +80 -80
- package/docs/templates/worker-dogfood-setup.md +68 -68
- package/docs/verification-matrix.md +70 -66
- package/examples/decision-gate-agent-dag.json +177 -177
- package/examples/example-dag.json +46 -46
- package/examples/hybrid-loop-agent-dag.json +189 -189
- package/harness.json +66 -66
- package/package.json +88 -46
- package/scripts/check-product-line-docs.sh +29 -29
- package/scripts/check-task-pool-root.sh +32 -32
- package/scripts/kb-bootstrap-init-skeleton.sh +240 -0
- package/scripts/kb-graph-incremental-prepare.mjs +386 -0
- package/scripts/kb-graph-incremental-prepare.sh +5 -0
- package/scripts/kb-graph-materialize.mjs +105 -0
- package/scripts/kb-graph-materialize.sh +4 -0
- package/scripts/kb-graph-promote.mjs +164 -0
- package/scripts/kb-graph-promote.sh +4 -0
- package/scripts/kb-query.mjs +554 -0
- package/scripts/kb-query.sh +5 -0
- package/skills/agent-worker/SKILL.md +39 -37
- package/skills/agent-worker/references/agent-worker-operator.md +60 -43
- package/skills/ai-engineering-context/SKILL.md +48 -48
- package/skills/analyze-product-dependencies/SKILL.md +67 -0
- package/skills/analyze-product-dependencies/agents/openai.yaml +4 -0
- package/skills/analyze-product-dependencies/references/api-documentation-schema.md +30 -0
- package/skills/analyze-product-dependencies/references/dependency-analysis-schema.md +28 -0
- package/skills/analyze-product-dependencies/references/example.md +76 -0
- package/skills/analyze-product-dependencies/references/forward-test-cases.md +35 -0
- package/skills/analyze-product-dependencies/references/input-contract.md +11 -0
- package/skills/analyze-product-dependencies/references/scouting-rules.md +61 -0
- package/skills/analyze-product-dependencies/scripts/test-validators.mjs +267 -0
- package/skills/analyze-product-dependencies/scripts/validate-api-documentation.mjs +101 -0
- package/skills/analyze-product-dependencies/scripts/validate-dependency-analysis.mjs +142 -0
- package/skills/analyze-product-dependencies/scripts/validate-product-requirement-input.mjs +76 -0
- package/skills/analyze-product-dependencies/scripts/validation-helpers.mjs +146 -0
- package/skills/analyze-product-requirements/SKILL.md +90 -0
- package/skills/analyze-product-requirements/agents/openai.yaml +4 -0
- package/skills/analyze-product-requirements/references/acceptance-criteria.md +91 -0
- package/skills/analyze-product-requirements/references/clarification-and-knowledge.md +56 -0
- package/skills/analyze-product-requirements/references/example.md +86 -0
- package/skills/analyze-product-requirements/references/forward-test-cases.md +66 -0
- package/skills/analyze-product-requirements/references/product-analysis-schema.md +32 -0
- package/skills/analyze-product-requirements/references/product-requirement-schema.md +33 -0
- package/skills/analyze-product-requirements/references/requirement-clarification-schema.md +35 -0
- package/skills/analyze-product-requirements/scripts/test-validators.mjs +193 -0
- package/skills/analyze-product-requirements/scripts/validate-product-analysis.mjs +69 -0
- package/skills/analyze-product-requirements/scripts/validate-product-requirement.mjs +97 -0
- package/skills/analyze-product-requirements/scripts/validate-requirement-clarification.mjs +98 -0
- package/skills/analyze-product-requirements/scripts/validation-helpers.mjs +156 -0
- package/skills/code-review-core/SKILL.md +20 -20
- package/skills/codebase-scout/SKILL.md +19 -19
- package/skills/frontend-design-review/SKILL.md +66 -59
- package/skills/frontend-design-review/references/review-checklist.md +58 -37
- package/skills/frontend-implementation/SKILL.md +47 -51
- package/skills/frontend-implementation/references/code-standards.md +32 -34
- package/skills/frontend-implementation/references/design-spec.md +46 -46
- package/skills/frontend-implementation/references/node-contracts.md +76 -32
- package/skills/frontend-review/SKILL.md +59 -53
- package/skills/frontend-review/references/review-findings.md +47 -42
- package/skills/frontend-verification/SKILL.md +53 -40
- package/skills/frontend-verification/references/verification-checklist.md +68 -56
- package/skills/grill-me/SKILL.md +10 -10
- package/skills/grill-with-docs/SKILL.md +88 -88
- package/skills/grill-with-docs/adr-format.md +47 -47
- package/skills/grill-with-docs/context-format.md +60 -60
- package/skills/init-capability-evolution/SKILL.md +70 -70
- package/skills/loop-agent/SKILL.md +151 -151
- package/skills/loop-agent/references/README.md +67 -67
- package/skills/loop-agent/references/command-reference.md +505 -452
- package/skills/loop-agent/references/docs-converge.md +126 -126
- package/skills/loop-agent/references/harness-policy.md +263 -263
- package/skills/loop-agent/references/hybrid-dag.md +238 -233
- package/skills/loop-agent/references/learned/README.md +21 -21
- package/skills/loop-agent/references/long-running-loop.md +57 -57
- package/skills/loop-agent/references/model-routing.md +36 -36
- package/skills/loop-agent/references/multi-worktree.md +54 -54
- package/skills/loop-agent/references/one-shot-runs.md +85 -85
- package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
- package/skills/loop-agent/references/pi-prompt.md +23 -23
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +84 -84
- package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
- package/skills/loop-agent/references/task-workflow.md +89 -89
- package/skills/loop-agent/references/verification-and-failure-handling.md +139 -139
- package/skills/playwright-cli/SKILL.md +420 -0
- package/skills/playwright-cli/references/element-attributes.md +23 -0
- package/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/skills/playwright-cli/references/request-mocking.md +87 -0
- package/skills/playwright-cli/references/running-code.md +241 -0
- package/skills/playwright-cli/references/session-management.md +225 -0
- package/skills/playwright-cli/references/storage-state.md +275 -0
- package/skills/playwright-cli/references/test-generation.md +433 -0
- package/skills/playwright-cli/references/tracing.md +139 -0
- package/skills/playwright-cli/references/video-recording.md +143 -0
- package/skills/playwright-cli-case-generator/SKILL.md +74 -0
- package/skills/requesting-code-review/SKILL.md +101 -101
- package/skills/requesting-code-review/code-reviewer.md +168 -168
- package/skills/systematic-debugging/CREATION-LOG.md +119 -119
- package/skills/systematic-debugging/SKILL.md +296 -296
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
- package/skills/systematic-debugging/condition-based-waiting.md +115 -115
- package/skills/systematic-debugging/defense-in-depth.md +122 -122
- package/skills/systematic-debugging/find-polluter.sh +63 -63
- package/skills/systematic-debugging/root-cause-tracing.md +169 -169
- package/skills/systematic-debugging/test-academic.md +14 -14
- package/skills/systematic-debugging/test-pressure-1.md +58 -58
- package/skills/systematic-debugging/test-pressure-2.md +68 -68
- package/skills/systematic-debugging/test-pressure-3.md +69 -69
- package/skills/test-driven-development/SKILL.md +20 -20
- package/skills/using-git-worktrees/SKILL.md +215 -215
- package/skills/verification-before-completion/SKILL.md +154 -154
- package/skills/webapp-testing/SKILL.md +19 -19
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFile, mkdir, readFile, rename, rm, unlink, writeFile, } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { diagnoseTaskPoolStates, loadOperatorMapping, } from "./doctor.js";
|
|
5
|
+
import { getEventsJsonlPath, getLegacyTaskStatePath, getRunsJsonlPath, getTaskPoolRoot, getTaskStatePath, pathExists, sha256File, sha256Text, } from "./run-store.js";
|
|
6
|
+
import { TASK_POOL_STATE_ERROR_CODES, TaskPoolStateError, } from "./types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Migrate legacy flat Task Pool states to feature-scoped v2.
|
|
9
|
+
* Defaults to dry-run (zero writes). Apply requires --owner and --reason.
|
|
10
|
+
*/
|
|
11
|
+
export async function migrateTaskPoolStates(input) {
|
|
12
|
+
const apply = input.apply === true;
|
|
13
|
+
const migrationId = input.migrationId ??
|
|
14
|
+
`migration-${(input.now ?? new Date()).toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`;
|
|
15
|
+
const mapping = input.mapping ?? (await loadOperatorMapping(input.mappingPath));
|
|
16
|
+
const doctor = await diagnoseTaskPoolStates(input.repoRoot, { mapping });
|
|
17
|
+
const planned = [];
|
|
18
|
+
const skipped = [];
|
|
19
|
+
for (const item of doctor.legacy) {
|
|
20
|
+
if (item.resolution === "ambiguous") {
|
|
21
|
+
skipped.push({
|
|
22
|
+
taskId: item.taskId,
|
|
23
|
+
reason: item.detail ?? "identity ambiguous",
|
|
24
|
+
code: TASK_POOL_STATE_ERROR_CODES.IDENTITY_AMBIGUOUS,
|
|
25
|
+
});
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (item.resolution === "unresolved" || !item.featureId) {
|
|
29
|
+
skipped.push({
|
|
30
|
+
taskId: item.taskId,
|
|
31
|
+
reason: item.detail ?? "no unique identity evidence",
|
|
32
|
+
code: TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED,
|
|
33
|
+
});
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const ref = {
|
|
37
|
+
featureId: item.featureId,
|
|
38
|
+
taskId: item.taskId,
|
|
39
|
+
};
|
|
40
|
+
const v2Path = getTaskStatePath(input.repoRoot, ref);
|
|
41
|
+
const alreadyMigrated = await pathExists(v2Path);
|
|
42
|
+
if (alreadyMigrated) {
|
|
43
|
+
// Idempotent: if v2 already exists with matching identity, skip write.
|
|
44
|
+
planned.push({
|
|
45
|
+
taskId: item.taskId,
|
|
46
|
+
legacyPath: item.legacyPath,
|
|
47
|
+
legacySha256: item.sha256,
|
|
48
|
+
featureId: item.featureId,
|
|
49
|
+
v2Path,
|
|
50
|
+
resolution: item.resolution,
|
|
51
|
+
alreadyMigrated: true,
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Conflict: v2 path occupied by different content is handled at apply time.
|
|
56
|
+
planned.push({
|
|
57
|
+
taskId: item.taskId,
|
|
58
|
+
legacyPath: item.legacyPath,
|
|
59
|
+
legacySha256: item.sha256,
|
|
60
|
+
featureId: item.featureId,
|
|
61
|
+
v2Path,
|
|
62
|
+
resolution: item.resolution,
|
|
63
|
+
alreadyMigrated: false,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const runsPath = getRunsJsonlPath(input.repoRoot);
|
|
67
|
+
const eventsPath = getEventsJsonlPath(input.repoRoot);
|
|
68
|
+
const runsHashBefore = (await pathExists(runsPath))
|
|
69
|
+
? await sha256File(runsPath)
|
|
70
|
+
: null;
|
|
71
|
+
const eventsHashBefore = (await pathExists(eventsPath))
|
|
72
|
+
? await sha256File(eventsPath)
|
|
73
|
+
: null;
|
|
74
|
+
if (!apply) {
|
|
75
|
+
return {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
mode: "dry-run",
|
|
78
|
+
status: planned.length === 0 && skipped.length === 0 ? "noop" : "planned",
|
|
79
|
+
migrationId,
|
|
80
|
+
planned,
|
|
81
|
+
skipped,
|
|
82
|
+
jsonlUnchanged: { runs: true, events: true },
|
|
83
|
+
doctor,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!input.owner?.trim()) {
|
|
87
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.CONFLICT, "pool migrate-state --apply requires a non-empty --owner");
|
|
88
|
+
}
|
|
89
|
+
if (!input.reason?.trim()) {
|
|
90
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.CONFLICT, "pool migrate-state --apply requires a non-empty --reason");
|
|
91
|
+
}
|
|
92
|
+
if (skipped.some((item) => item.code === TASK_POOL_STATE_ERROR_CODES.IDENTITY_AMBIGUOUS)) {
|
|
93
|
+
return {
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
mode: "apply",
|
|
96
|
+
status: "failed",
|
|
97
|
+
migrationId,
|
|
98
|
+
owner: input.owner,
|
|
99
|
+
reason: input.reason,
|
|
100
|
+
planned,
|
|
101
|
+
skipped,
|
|
102
|
+
jsonlUnchanged: { runs: true, events: true },
|
|
103
|
+
doctor,
|
|
104
|
+
error: {
|
|
105
|
+
code: TASK_POOL_STATE_ERROR_CODES.IDENTITY_AMBIGUOUS,
|
|
106
|
+
message: "refusing apply while any legacy identity is ambiguous",
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const actionable = planned.filter((item) => !item.alreadyMigrated);
|
|
111
|
+
if (actionable.length === 0 && planned.every((item) => item.alreadyMigrated)) {
|
|
112
|
+
// Idempotent re-run: move any remaining legacy files that match already-migrated v2.
|
|
113
|
+
// If legacy still present with alreadyMigrated, still archive them.
|
|
114
|
+
}
|
|
115
|
+
const artifactDir = path.join(getTaskPoolRoot(input.repoRoot), "artifacts", "migrations", migrationId);
|
|
116
|
+
const beforeDir = path.join(artifactDir, "before");
|
|
117
|
+
const stagingDir = path.join(artifactDir, "staging");
|
|
118
|
+
const writtenV2 = [];
|
|
119
|
+
const movedLegacy = [];
|
|
120
|
+
try {
|
|
121
|
+
await mkdir(beforeDir, { recursive: true });
|
|
122
|
+
await mkdir(stagingDir, { recursive: true });
|
|
123
|
+
// Stage all v2 payloads first.
|
|
124
|
+
const staged = [];
|
|
125
|
+
for (const item of planned) {
|
|
126
|
+
const legacyBytes = await readFile(item.legacyPath);
|
|
127
|
+
const legacyText = legacyBytes.toString("utf-8");
|
|
128
|
+
const hash = await sha256Text(legacyText);
|
|
129
|
+
if (hash !== item.legacySha256) {
|
|
130
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.CONFLICT, `legacy state changed during migration: ${item.taskId}`);
|
|
131
|
+
}
|
|
132
|
+
const parsed = JSON.parse(legacyText);
|
|
133
|
+
const payload = {
|
|
134
|
+
...parsed,
|
|
135
|
+
schemaVersion: 2,
|
|
136
|
+
featureId: item.featureId,
|
|
137
|
+
taskId: item.taskId,
|
|
138
|
+
};
|
|
139
|
+
const stagedPath = path.join(stagingDir, `${item.featureId}__${item.taskId}.json`);
|
|
140
|
+
await writeFile(stagedPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
|
141
|
+
staged.push({ item, stagedPath, payload, legacyBytes });
|
|
142
|
+
}
|
|
143
|
+
// Conflict checks against existing v2 targets.
|
|
144
|
+
for (const entry of staged) {
|
|
145
|
+
if (await pathExists(entry.item.v2Path)) {
|
|
146
|
+
const existing = JSON.parse(await readFile(entry.item.v2Path, "utf-8"));
|
|
147
|
+
if (existing.featureId === entry.item.featureId &&
|
|
148
|
+
existing.taskId === entry.item.taskId) {
|
|
149
|
+
// already migrated — ok, will only archive legacy
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.CONFLICT, `v2 target already exists with different identity: ${entry.item.v2Path}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Atomic rename of staged v2 files.
|
|
156
|
+
for (const entry of staged) {
|
|
157
|
+
if (await pathExists(entry.item.v2Path)) {
|
|
158
|
+
// already migrated
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
await mkdir(path.dirname(entry.item.v2Path), { recursive: true });
|
|
162
|
+
const tempPath = `${entry.item.v2Path}.${randomUUID()}.tmp`;
|
|
163
|
+
try {
|
|
164
|
+
await copyFile(entry.stagedPath, tempPath);
|
|
165
|
+
await input.faultInjector?.("before-v2-rename", entry.item.taskId);
|
|
166
|
+
await rename(tempPath, entry.item.v2Path);
|
|
167
|
+
writtenV2.push(entry.item.v2Path);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await unlink(tempPath).catch(() => { });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await input.faultInjector?.("after-partial-v2");
|
|
174
|
+
// Move legacy into artifact before/.
|
|
175
|
+
for (const entry of staged) {
|
|
176
|
+
const dest = path.join(beforeDir, path.basename(entry.item.legacyPath));
|
|
177
|
+
await input.faultInjector?.("before-legacy-move", entry.item.taskId);
|
|
178
|
+
if (await pathExists(entry.item.legacyPath)) {
|
|
179
|
+
// Preserve exact bytes
|
|
180
|
+
await writeFile(dest, entry.legacyBytes);
|
|
181
|
+
await unlink(entry.item.legacyPath);
|
|
182
|
+
movedLegacy.push({
|
|
183
|
+
from: entry.item.legacyPath,
|
|
184
|
+
to: dest,
|
|
185
|
+
originalBytes: entry.legacyBytes,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const mappingDoc = {
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
migrationId,
|
|
192
|
+
items: planned.map((item) => ({
|
|
193
|
+
taskId: item.taskId,
|
|
194
|
+
featureId: item.featureId,
|
|
195
|
+
resolution: item.resolution,
|
|
196
|
+
legacySha256: item.legacySha256,
|
|
197
|
+
v2Path: item.v2Path,
|
|
198
|
+
alreadyMigrated: item.alreadyMigrated,
|
|
199
|
+
})),
|
|
200
|
+
skipped,
|
|
201
|
+
};
|
|
202
|
+
const manifest = {
|
|
203
|
+
schemaVersion: 1,
|
|
204
|
+
migrationId,
|
|
205
|
+
owner: input.owner,
|
|
206
|
+
reason: input.reason,
|
|
207
|
+
appliedAt: (input.now ?? new Date()).toISOString(),
|
|
208
|
+
plannedCount: planned.length,
|
|
209
|
+
skippedCount: skipped.length,
|
|
210
|
+
};
|
|
211
|
+
const resultDoc = {
|
|
212
|
+
schemaVersion: 1,
|
|
213
|
+
status: "applied",
|
|
214
|
+
migrationId,
|
|
215
|
+
writtenV2,
|
|
216
|
+
archivedLegacy: movedLegacy.map((item) => item.to),
|
|
217
|
+
};
|
|
218
|
+
await writeFile(path.join(artifactDir, "mapping.json"), `${JSON.stringify(mappingDoc, null, 2)}\n`, "utf-8");
|
|
219
|
+
await writeFile(path.join(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
220
|
+
await writeFile(path.join(artifactDir, "result.json"), `${JSON.stringify(resultDoc, null, 2)}\n`, "utf-8");
|
|
221
|
+
// Clean staging
|
|
222
|
+
await rm(stagingDir, { recursive: true, force: true }).catch(() => { });
|
|
223
|
+
const runsHashAfter = (await pathExists(runsPath))
|
|
224
|
+
? await sha256File(runsPath)
|
|
225
|
+
: null;
|
|
226
|
+
const eventsHashAfter = (await pathExists(eventsPath))
|
|
227
|
+
? await sha256File(eventsPath)
|
|
228
|
+
: null;
|
|
229
|
+
return {
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
mode: "apply",
|
|
232
|
+
status: "applied",
|
|
233
|
+
migrationId,
|
|
234
|
+
owner: input.owner,
|
|
235
|
+
reason: input.reason,
|
|
236
|
+
planned,
|
|
237
|
+
skipped,
|
|
238
|
+
artifactDir,
|
|
239
|
+
jsonlUnchanged: {
|
|
240
|
+
runs: runsHashBefore === runsHashAfter,
|
|
241
|
+
events: eventsHashBefore === eventsHashAfter,
|
|
242
|
+
},
|
|
243
|
+
doctor,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
// Full rollback: restore legacy, remove half-written v2.
|
|
248
|
+
for (const v2Path of writtenV2) {
|
|
249
|
+
await unlink(v2Path).catch(() => { });
|
|
250
|
+
}
|
|
251
|
+
for (const moved of movedLegacy) {
|
|
252
|
+
await writeFile(moved.from, moved.originalBytes).catch(() => { });
|
|
253
|
+
await unlink(moved.to).catch(() => { });
|
|
254
|
+
}
|
|
255
|
+
// Also restore any legacy that was deleted without movedLegacy tracking
|
|
256
|
+
// (should not happen with current flow).
|
|
257
|
+
for (const item of planned) {
|
|
258
|
+
if (!(await pathExists(item.legacyPath))) {
|
|
259
|
+
// try recover from beforeDir if present
|
|
260
|
+
const dest = path.join(beforeDir, path.basename(item.legacyPath));
|
|
261
|
+
if (await pathExists(dest)) {
|
|
262
|
+
await copyFile(dest, item.legacyPath).catch(() => { });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!item.alreadyMigrated &&
|
|
266
|
+
(await pathExists(item.v2Path)) &&
|
|
267
|
+
writtenV2.includes(item.v2Path)) {
|
|
268
|
+
await unlink(item.v2Path).catch(() => { });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await rm(stagingDir, { recursive: true, force: true }).catch(() => { });
|
|
272
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
273
|
+
const code = error instanceof TaskPoolStateError
|
|
274
|
+
? error.code
|
|
275
|
+
: TASK_POOL_STATE_ERROR_CODES.CONFLICT;
|
|
276
|
+
return {
|
|
277
|
+
schemaVersion: 1,
|
|
278
|
+
mode: "apply",
|
|
279
|
+
status: "failed",
|
|
280
|
+
migrationId,
|
|
281
|
+
owner: input.owner,
|
|
282
|
+
reason: input.reason,
|
|
283
|
+
planned,
|
|
284
|
+
skipped,
|
|
285
|
+
artifactDir,
|
|
286
|
+
jsonlUnchanged: { runs: true, events: true },
|
|
287
|
+
doctor,
|
|
288
|
+
error: { code, message: detail },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
/** Helper for tests: write a legacy flat state file without going through writeTaskPoolState. */
|
|
293
|
+
export async function writeLegacyTaskPoolState(repoRoot, state) {
|
|
294
|
+
const statePath = getLegacyTaskStatePath(repoRoot, state.taskId);
|
|
295
|
+
await mkdir(path.dirname(statePath), { recursive: true });
|
|
296
|
+
const payload = {
|
|
297
|
+
schemaVersion: 1,
|
|
298
|
+
...state,
|
|
299
|
+
};
|
|
300
|
+
delete payload.featureId;
|
|
301
|
+
await writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
|
302
|
+
return statePath;
|
|
303
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { appendFile, mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile, } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { TASK_POOL_STATE_ERROR_CODES, TaskPoolStateError, } from "./types.js";
|
|
4
5
|
export const TASK_POOL_RELATIVE_ROOT = ".harness/task-pool";
|
|
6
|
+
const SAFE_TASK_POOL_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
5
7
|
export function getTaskPoolRoot(repoRoot) {
|
|
6
8
|
return path.join(repoRoot, ...TASK_POOL_RELATIVE_ROOT.split("/"));
|
|
7
9
|
}
|
|
@@ -11,8 +13,41 @@ export function getRunsJsonlPath(repoRoot) {
|
|
|
11
13
|
export function getEventsJsonlPath(repoRoot) {
|
|
12
14
|
return path.join(getTaskPoolRoot(repoRoot), "events.jsonl");
|
|
13
15
|
}
|
|
14
|
-
export function
|
|
15
|
-
return path.join(getTaskPoolRoot(repoRoot), "states"
|
|
16
|
+
export function getStatesRoot(repoRoot) {
|
|
17
|
+
return path.join(getTaskPoolRoot(repoRoot), "states");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Fail-closed id check for Task Pool path segments.
|
|
21
|
+
* Mirrors follow-up runtime id charset without importing that module.
|
|
22
|
+
*/
|
|
23
|
+
export function assertSafeTaskPoolId(value, label) {
|
|
24
|
+
if (typeof value !== "string" || value.length === 0 || !SAFE_TASK_POOL_ID.test(value)) {
|
|
25
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.UNSAFE_ID, `${label} contains unsafe path characters: ${JSON.stringify(value)}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function assertWithinStatesRoot(repoRoot, candidatePath) {
|
|
29
|
+
const statesRoot = path.resolve(getStatesRoot(repoRoot));
|
|
30
|
+
const resolved = path.resolve(candidatePath);
|
|
31
|
+
const relative = path.relative(statesRoot, resolved);
|
|
32
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
33
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH, `Task Pool state path escapes states root: ${candidatePath}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function getTaskStatePath(repoRoot, refOrTaskId) {
|
|
37
|
+
if (typeof refOrTaskId === "string") {
|
|
38
|
+
assertSafeTaskPoolId(refOrTaskId, "taskId");
|
|
39
|
+
const statePath = path.join(getStatesRoot(repoRoot), `${refOrTaskId}.json`);
|
|
40
|
+
assertWithinStatesRoot(repoRoot, statePath);
|
|
41
|
+
return statePath;
|
|
42
|
+
}
|
|
43
|
+
assertSafeTaskPoolId(refOrTaskId.featureId, "featureId");
|
|
44
|
+
assertSafeTaskPoolId(refOrTaskId.taskId, "taskId");
|
|
45
|
+
const statePath = path.join(getStatesRoot(repoRoot), refOrTaskId.featureId, `${refOrTaskId.taskId}.json`);
|
|
46
|
+
assertWithinStatesRoot(repoRoot, statePath);
|
|
47
|
+
return statePath;
|
|
48
|
+
}
|
|
49
|
+
export function getLegacyTaskStatePath(repoRoot, taskId) {
|
|
50
|
+
return getTaskStatePath(repoRoot, taskId);
|
|
16
51
|
}
|
|
17
52
|
export async function recordTaskPoolRun(input) {
|
|
18
53
|
await ensurePoolDirs(input.repoRoot);
|
|
@@ -30,10 +65,12 @@ export async function findRunByWorkerRunId(repoRoot, workerRunId) {
|
|
|
30
65
|
}
|
|
31
66
|
/**
|
|
32
67
|
* Explicitly requeue a failed task without deleting or rewriting prior evidence.
|
|
33
|
-
*
|
|
68
|
+
* Prefer explicit featureId. Bare taskId is allowed only when exactly one v2 state matches.
|
|
34
69
|
*/
|
|
35
70
|
export async function prepareTaskPoolRetry(input) {
|
|
36
|
-
const
|
|
71
|
+
const featureId = await resolveRetryFeatureId(input.repoRoot, input.taskId, input.featureId);
|
|
72
|
+
const ref = { featureId, taskId: input.taskId };
|
|
73
|
+
const current = await readTaskPoolState(input.repoRoot, ref);
|
|
37
74
|
if (!current) {
|
|
38
75
|
throw new Error(`cannot retry ${input.taskId}: Task Pool state does not exist`);
|
|
39
76
|
}
|
|
@@ -46,12 +83,15 @@ export async function prepareTaskPoolRetry(input) {
|
|
|
46
83
|
const retryRequestedAt = (input.now ?? new Date()).toISOString();
|
|
47
84
|
const result = {
|
|
48
85
|
taskId: input.taskId,
|
|
86
|
+
featureId,
|
|
49
87
|
status: "Ready",
|
|
50
88
|
previousWorkerRunId: current.workerRunId,
|
|
51
89
|
retryRequestedAt,
|
|
52
90
|
...(input.reason ? { reason: input.reason } : {}),
|
|
53
91
|
};
|
|
54
92
|
await writeTaskPoolState(input.repoRoot, {
|
|
93
|
+
schemaVersion: 2,
|
|
94
|
+
featureId,
|
|
55
95
|
taskId: current.taskId,
|
|
56
96
|
status: "Ready",
|
|
57
97
|
updatedAt: retryRequestedAt,
|
|
@@ -65,31 +105,72 @@ export async function prepareTaskPoolRetry(input) {
|
|
|
65
105
|
at: retryRequestedAt,
|
|
66
106
|
type: "task-retry-requested",
|
|
67
107
|
taskId: input.taskId,
|
|
108
|
+
featureId,
|
|
68
109
|
previousWorkerRunId: current.workerRunId,
|
|
69
110
|
...(input.reason ? { reason: input.reason } : {}),
|
|
70
111
|
});
|
|
71
112
|
return result;
|
|
72
113
|
}
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
async function resolveRetryFeatureId(repoRoot, taskId, featureId) {
|
|
115
|
+
if (featureId) {
|
|
116
|
+
assertSafeTaskPoolId(featureId, "featureId");
|
|
117
|
+
return featureId;
|
|
118
|
+
}
|
|
119
|
+
const legacyPath = getLegacyTaskStatePath(repoRoot, taskId);
|
|
120
|
+
if (await pathExists(legacyPath)) {
|
|
121
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED, `cannot retry ${taskId}: legacy state requires migration`);
|
|
122
|
+
}
|
|
123
|
+
const matches = (await listTaskPoolStates(repoRoot)).filter((state) => state.taskId === taskId);
|
|
124
|
+
if (matches.length === 1 && matches[0]?.featureId) {
|
|
125
|
+
return matches[0].featureId;
|
|
126
|
+
}
|
|
127
|
+
if (matches.length > 1) {
|
|
128
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.IDENTITY_AMBIGUOUS, `cannot retry ${taskId}: multiple feature-scoped states exist`);
|
|
129
|
+
}
|
|
130
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.FEATURE_REQUIRED, `cannot retry ${taskId}: featureId is required when no unique v2 state exists`);
|
|
131
|
+
}
|
|
132
|
+
export async function readTaskPoolState(repoRoot, refOrTaskId) {
|
|
133
|
+
if (typeof refOrTaskId === "string") {
|
|
134
|
+
const statePath = getTaskStatePath(repoRoot, refOrTaskId);
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(await readFile(statePath, "utf-8"));
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (isNotFound(error))
|
|
140
|
+
return undefined;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const statePath = getTaskStatePath(repoRoot, refOrTaskId);
|
|
145
|
+
let raw;
|
|
75
146
|
try {
|
|
76
|
-
|
|
147
|
+
raw = await readFile(statePath, "utf-8");
|
|
77
148
|
}
|
|
78
149
|
catch (error) {
|
|
79
150
|
if (isNotFound(error))
|
|
80
151
|
return undefined;
|
|
81
152
|
throw error;
|
|
82
153
|
}
|
|
154
|
+
const state = JSON.parse(raw);
|
|
155
|
+
if (state.taskId !== refOrTaskId.taskId ||
|
|
156
|
+
state.featureId !== refOrTaskId.featureId) {
|
|
157
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.PATH_MISMATCH, `Task Pool state path/content identity mismatch at ${statePath}: path=${refOrTaskId.featureId}/${refOrTaskId.taskId} content=${state.featureId ?? "<missing>"}/${state.taskId}`);
|
|
158
|
+
}
|
|
159
|
+
return state;
|
|
83
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* @deprecated Legacy-only flat scan. Does not include feature-scoped v2 states.
|
|
163
|
+
* Prefer readFeatureTaskPoolStates / listTaskPoolStates. Removal targeted for M2.
|
|
164
|
+
*/
|
|
84
165
|
export async function readAllTaskPoolStates(repoRoot) {
|
|
85
|
-
const stateDir =
|
|
166
|
+
const stateDir = getStatesRoot(repoRoot);
|
|
86
167
|
try {
|
|
87
|
-
const entries = await readdir(stateDir);
|
|
168
|
+
const entries = await readdir(stateDir, { withFileTypes: true });
|
|
88
169
|
const states = {};
|
|
89
170
|
for (const entry of entries) {
|
|
90
|
-
if (!entry.endsWith(".json"))
|
|
171
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
91
172
|
continue;
|
|
92
|
-
const raw = await readFile(path.join(stateDir, entry), "utf-8");
|
|
173
|
+
const raw = await readFile(path.join(stateDir, entry.name), "utf-8");
|
|
93
174
|
const state = JSON.parse(raw);
|
|
94
175
|
states[state.taskId] = state;
|
|
95
176
|
}
|
|
@@ -101,12 +182,79 @@ export async function readAllTaskPoolStates(repoRoot) {
|
|
|
101
182
|
throw error;
|
|
102
183
|
}
|
|
103
184
|
}
|
|
185
|
+
/** Canonical Feature-scoped list (v2 paths only). */
|
|
186
|
+
export async function readFeatureTaskPoolStates(repoRoot, featureId) {
|
|
187
|
+
assertSafeTaskPoolId(featureId, "featureId");
|
|
188
|
+
const featureDir = path.join(getStatesRoot(repoRoot), featureId);
|
|
189
|
+
assertWithinStatesRoot(repoRoot, featureDir);
|
|
190
|
+
const states = {};
|
|
191
|
+
try {
|
|
192
|
+
const entries = await readdir(featureDir);
|
|
193
|
+
for (const entry of entries) {
|
|
194
|
+
if (!entry.endsWith(".json"))
|
|
195
|
+
continue;
|
|
196
|
+
const taskId = entry.slice(0, -5);
|
|
197
|
+
const state = await readTaskPoolState(repoRoot, { featureId, taskId });
|
|
198
|
+
if (state)
|
|
199
|
+
states[taskId] = state;
|
|
200
|
+
}
|
|
201
|
+
return states;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (isNotFound(error))
|
|
205
|
+
return {};
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** List all schema v2 feature-scoped states (never a covering Record by bare taskId). */
|
|
210
|
+
export async function listTaskPoolStates(repoRoot) {
|
|
211
|
+
const stateDir = getStatesRoot(repoRoot);
|
|
212
|
+
const result = [];
|
|
213
|
+
try {
|
|
214
|
+
const entries = await readdir(stateDir, { withFileTypes: true });
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
if (!entry.isDirectory())
|
|
217
|
+
continue;
|
|
218
|
+
const featureId = entry.name;
|
|
219
|
+
if (!SAFE_TASK_POOL_ID.test(featureId))
|
|
220
|
+
continue;
|
|
221
|
+
const featureStates = await readFeatureTaskPoolStates(repoRoot, featureId);
|
|
222
|
+
result.push(...Object.values(featureStates));
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (isNotFound(error))
|
|
228
|
+
return [];
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Write Task Pool state.
|
|
234
|
+
* Requires featureId (schema v2). Throws if featureId missing or legacy flat file exists.
|
|
235
|
+
*/
|
|
104
236
|
export async function writeTaskPoolState(repoRoot, state) {
|
|
105
|
-
|
|
237
|
+
if (!state.featureId) {
|
|
238
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.FEATURE_REQUIRED, `writeTaskPoolState requires featureId for task ${state.taskId}`);
|
|
239
|
+
}
|
|
240
|
+
assertSafeTaskPoolId(state.featureId, "featureId");
|
|
241
|
+
assertSafeTaskPoolId(state.taskId, "taskId");
|
|
242
|
+
const legacyPath = getLegacyTaskStatePath(repoRoot, state.taskId);
|
|
243
|
+
if (await pathExists(legacyPath)) {
|
|
244
|
+
throw new TaskPoolStateError(TASK_POOL_STATE_ERROR_CODES.MIGRATION_REQUIRED, `cannot write v2 state for ${state.taskId}: legacy state exists at ${legacyPath}`);
|
|
245
|
+
}
|
|
246
|
+
const ref = { featureId: state.featureId, taskId: state.taskId };
|
|
247
|
+
const statePath = getTaskStatePath(repoRoot, ref);
|
|
248
|
+
const payload = {
|
|
249
|
+
...state,
|
|
250
|
+
schemaVersion: 2,
|
|
251
|
+
featureId: state.featureId,
|
|
252
|
+
taskId: state.taskId,
|
|
253
|
+
};
|
|
106
254
|
await mkdir(path.dirname(statePath), { recursive: true });
|
|
107
255
|
const tempPath = path.join(path.dirname(statePath), `.${path.basename(statePath)}.${randomUUID()}.tmp`);
|
|
108
256
|
try {
|
|
109
|
-
await writeFile(tempPath, `${JSON.stringify(
|
|
257
|
+
await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
|
|
110
258
|
await rename(tempPath, statePath);
|
|
111
259
|
}
|
|
112
260
|
finally {
|
|
@@ -127,6 +275,43 @@ export async function readJsonlFile(filePath) {
|
|
|
127
275
|
throw error;
|
|
128
276
|
}
|
|
129
277
|
}
|
|
278
|
+
export async function sha256Text(text) {
|
|
279
|
+
return createHash("sha256").update(text, "utf-8").digest("hex");
|
|
280
|
+
}
|
|
281
|
+
export async function sha256File(filePath) {
|
|
282
|
+
const raw = await readFile(filePath);
|
|
283
|
+
return createHash("sha256").update(raw).digest("hex");
|
|
284
|
+
}
|
|
285
|
+
export async function listLegacyStateFiles(repoRoot) {
|
|
286
|
+
const stateDir = getStatesRoot(repoRoot);
|
|
287
|
+
try {
|
|
288
|
+
const entries = await readdir(stateDir, { withFileTypes: true });
|
|
289
|
+
const result = [];
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
if (!entry.isFile() || !entry.name.endsWith(".json"))
|
|
292
|
+
continue;
|
|
293
|
+
const taskId = entry.name.slice(0, -5);
|
|
294
|
+
result.push({ taskId, path: path.join(stateDir, entry.name) });
|
|
295
|
+
}
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
if (isNotFound(error))
|
|
300
|
+
return [];
|
|
301
|
+
throw error;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
export async function pathExists(filePath) {
|
|
305
|
+
try {
|
|
306
|
+
await stat(filePath);
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (isNotFound(error))
|
|
311
|
+
return false;
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
130
315
|
async function appendJsonlFile(filePath, value) {
|
|
131
316
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
132
317
|
try {
|
|
@@ -140,10 +325,12 @@ async function appendJsonlFile(filePath, value) {
|
|
|
140
325
|
}
|
|
141
326
|
}
|
|
142
327
|
async function ensurePoolDirs(repoRoot) {
|
|
143
|
-
await mkdir(
|
|
328
|
+
await mkdir(getStatesRoot(repoRoot), { recursive: true });
|
|
144
329
|
}
|
|
145
|
-
function stateFromRun(run) {
|
|
330
|
+
export function stateFromRun(run) {
|
|
146
331
|
return {
|
|
332
|
+
schemaVersion: 2,
|
|
333
|
+
featureId: run.featureId,
|
|
147
334
|
taskId: run.taskId,
|
|
148
335
|
status: stateStatusFromRun(run),
|
|
149
336
|
updatedAt: run.recordedAt,
|
|
@@ -175,6 +362,7 @@ function eventFromRun(run) {
|
|
|
175
362
|
batchRunId: run.batchRunId,
|
|
176
363
|
workerRunId: run.workerRunId,
|
|
177
364
|
taskId: run.taskId,
|
|
365
|
+
featureId: run.featureId,
|
|
178
366
|
status: run.status,
|
|
179
367
|
};
|
|
180
368
|
}
|
|
@@ -1 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
/** Stable error codes for Task Pool state identity / migration. */
|
|
2
|
+
export const TASK_POOL_STATE_ERROR_CODES = {
|
|
3
|
+
MIGRATION_REQUIRED: "task-pool-state-migration-required",
|
|
4
|
+
IDENTITY_AMBIGUOUS: "task-pool-state-identity-ambiguous",
|
|
5
|
+
PATH_MISMATCH: "task-pool-state-path-mismatch",
|
|
6
|
+
CONFLICT: "task-pool-state-conflict",
|
|
7
|
+
FEATURE_REQUIRED: "task-pool-state-feature-required",
|
|
8
|
+
UNSAFE_ID: "task-pool-state-unsafe-id",
|
|
9
|
+
};
|
|
10
|
+
export class TaskPoolStateError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "TaskPoolStateError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|