@tea-agent/loop-agent 0.8.0 → 0.10.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.
- package/AGENTS.md +10 -0
- package/CHANGELOG.md +101 -1
- package/README.md +69 -5
- package/dist/application/dag/args.js +13 -16
- package/dist/application/dag/generate-task-dag.js +32 -2
- package/dist/application/dag/run-dag.js +1 -27
- package/dist/application/dag/validate-dag.js +2 -2
- package/dist/application/loop/run-action.js +0 -4
- package/dist/cli/command-definitions.js +7 -11
- package/dist/cli/program.js +9 -21
- package/dist/commands/cursor-prompt.js +42 -82
- package/dist/commands/dag-approve.js +36 -0
- package/dist/commands/dag-reconcile-run.js +118 -0
- package/dist/commands/delegate.js +75 -77
- package/dist/commands/doctor.js +0 -18
- package/dist/commands/init.js +60 -40
- package/dist/commands/instructions.js +7 -10
- package/dist/commands/loop.js +4 -20
- package/dist/executors/config-core.js +0 -51
- package/dist/executors/dag-pi-executor.js +1 -1
- package/dist/executors/dag.js +0 -1
- package/dist/executors/index.js +0 -2
- package/dist/executors/model-routing.js +9 -9
- package/dist/executors/shell-executor.js +75 -9
- package/dist/governance/checks.js +6 -3
- package/dist/governance/manifest-types.js +33 -2
- package/dist/infrastructure/harness/loop-action-store.js +0 -3
- package/dist/records/harvest.js +2 -23
- package/dist/records/one-shot-runs.js +1 -1
- package/dist/shared/artifacts-core.js +24 -5
- package/dist/shared/output-truncation.js +37 -0
- package/dist/shared/package-metadata.js +353 -0
- package/dist/shared/reference-context.js +48 -22
- package/dist/{executors/cursor-executor.js → sidecars/cursor-prompt/executor.js} +2 -42
- package/dist/sidecars/cursor-prompt/index.js +3 -0
- package/dist/sidecars/cursor-prompt/stream.js +121 -0
- package/dist/task/config-types.js +30 -13
- package/dist/task/delegate.js +9 -21
- package/dist/task/runtime.js +2 -3
- package/dist/worker/cli.js +243 -0
- package/dist/worker/closeout/apply.js +73 -0
- package/dist/worker/closeout/preview.js +30 -0
- package/dist/worker/delivery/final-verification.js +194 -0
- package/dist/worker/delivery/git-transaction.js +354 -0
- package/dist/worker/delivery/package.js +502 -0
- package/dist/worker/feature/decision-loader.js +68 -0
- package/dist/worker/feature/discover.js +14 -0
- package/dist/worker/feature/next-action.js +74 -0
- package/dist/worker/feature/reducer.js +133 -0
- package/dist/worker/feature/review.js +502 -0
- package/dist/worker/feature/run.js +365 -0
- package/dist/worker/feature/types.js +1 -0
- package/dist/worker/follow-up/approve.js +270 -0
- package/dist/worker/follow-up/factory.js +234 -0
- package/dist/worker/follow-up/paths.js +25 -0
- package/dist/worker/follow-up/policy.js +26 -0
- package/dist/worker/follow-up/schema.js +93 -0
- package/dist/worker/follow-up/store.js +96 -0
- package/dist/worker/loop-agent/loop-agent-client.js +345 -101
- package/dist/worker/metrics/projector.js +139 -0
- package/dist/worker/observability/read-model.js +282 -15
- package/dist/worker/observe/paths.js +17 -5
- package/dist/worker/observe/routes.js +78 -20
- package/dist/worker/observe/server.js +8 -6
- package/dist/worker/observe/static/app.js +1045 -177
- package/dist/worker/observe/static/index.html +70 -43
- package/dist/worker/observe/static/styles.css +553 -610
- package/dist/worker/pool/run-store.js +14 -2
- package/dist/worker/pool/validation.js +59 -0
- package/dist/worker/preflight.js +49 -1
- package/dist/worker/report/morning-report.js +41 -6
- package/dist/worker/run-task/run-task.js +23 -13
- package/dist/worker/runner/run-ready.js +89 -11
- package/dist/worker/task-spec/schema.js +0 -1
- package/dist/workflows/dag/convergence/controller.js +1 -1
- package/dist/workflows/dag/executor-registry.js +0 -2
- package/dist/workflows/dag/governance-profile.js +10 -0
- package/dist/workflows/dag/init-hybrid.js +601 -26
- package/dist/workflows/dag/lifecycle.js +146 -0
- package/dist/workflows/dag/node-execution.js +64 -7
- package/dist/workflows/dag/prompt.js +16 -0
- package/dist/workflows/dag/report.js +2 -0
- package/dist/workflows/dag/runner.js +176 -119
- package/dist/workflows/dag/scheduler.js +7 -2
- package/dist/workflows/dag/skill-snapshot.js +527 -0
- package/dist/workflows/dag/types.js +45 -9
- package/dist/workflows/dag/validate.js +5 -8
- package/dist/workflows/loop/actions/dag-action.js +0 -2
- package/dist/workflows/loop/actions/shared.js +1 -1
- package/dist/workflows/loop/actions.js +14 -31
- package/dist/workflows/loop/benchmark.js +1 -1
- package/dist/workflows/loop/index.js +1 -1
- package/dist/workflows/loop/policy/auto-policy.js +22 -14
- package/dist/workflows/loop/policy/path-patterns.js +13 -0
- package/docs/README.md +35 -12
- package/docs/agent-dag-recovery-playbook.md +1 -1
- package/docs/architecture/README.md +26 -0
- package/docs/architecture/dag-execution.md +134 -0
- package/docs/architecture/evolution.md +52 -0
- package/docs/architecture/facts-and-state.md +58 -0
- package/docs/architecture/runtime-boundaries.md +41 -15
- package/docs/architecture/system-overview.md +93 -0
- package/docs/architecture/worker-and-feature.md +81 -0
- package/docs/cursor-prompt-sidecar.md +36 -0
- package/docs/decisions/README.md +13 -1
- package/docs/design/README.md +39 -13
- package/docs/development-principles.md +1 -1
- package/docs/exec-plans/active/README.md +2 -2
- package/docs/exec-plans/completed/README.md +21 -0
- package/docs/feature-workflow.md +44 -4
- package/docs/init-surface.manifest.json +63 -1
- package/docs/loop-agent-harness.md +65 -3
- package/docs/progress/README.md +27 -0
- package/docs/reports/README.md +74 -5
- package/docs/skills/README.md +2 -1
- package/docs/skills/vetted-skill-registry.md +2 -1
- package/docs/templates/agent-dag-report.schema.json +4 -2
- package/docs/templates/agent-dag.base.json +0 -5
- package/docs/templates/agent-dag.final-verification.json +0 -5
- package/docs/templates/agent-dag.schema.json +1 -2
- package/docs/templates/agent-dag.supervised-implementation.json +1 -6
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +131 -0
- package/docs/templates/backend-test-dag.json +213 -0
- package/docs/templates/backend-test-dag.retrospect.prompt.md +128 -0
- package/docs/templates/backend-test-dag.review-cases.prompt.md +85 -0
- package/docs/templates/frontend-design-contract.md +33 -0
- package/docs/templates/frontend-task-constraints.md +25 -0
- package/docs/templates/frontend-task-requirement.md +61 -0
- package/docs/templates/harness.schema.json +8 -5
- package/docs/templates/hybrid-dag.json +1 -6
- package/docs/templates/init-evolution-review.md +4 -2
- package/docs/templates/interactive-ui-round2-experiment.md +1 -1
- package/docs/templates/product-line/task.yaml +0 -1
- package/docs/templates/worker-dogfood-evidence.md +28 -0
- package/docs/templates/worker-dogfood-setup.md +20 -0
- package/docs/verification-matrix.md +17 -0
- package/examples/decision-gate-agent-dag.json +87 -33
- package/examples/example-dag.json +0 -5
- package/examples/hybrid-loop-agent-dag.json +0 -5
- package/harness.json +6 -11
- package/package.json +22 -44
- package/scripts/check-product-line-docs.sh +10 -3
- package/scripts/check-task-pool-root.sh +1 -1
- package/skills/agent-worker/SKILL.md +37 -0
- package/skills/agent-worker/references/agent-worker-operator.md +43 -0
- package/skills/frontend-design-review/SKILL.md +59 -0
- package/skills/frontend-design-review/references/review-checklist.md +37 -0
- package/skills/frontend-implementation/SKILL.md +48 -0
- package/skills/frontend-implementation/references/code-standards.md +34 -0
- package/skills/frontend-implementation/references/design-spec.md +46 -0
- package/skills/frontend-implementation/references/node-contracts.md +32 -0
- package/skills/frontend-review/SKILL.md +53 -0
- package/skills/frontend-review/references/review-findings.md +42 -0
- package/skills/frontend-verification/SKILL.md +40 -0
- package/skills/frontend-verification/references/verification-checklist.md +56 -0
- package/skills/grill-me/SKILL.md +10 -0
- package/skills/grill-with-docs/SKILL.md +88 -0
- package/skills/grill-with-docs/adr-format.md +47 -0
- package/skills/grill-with-docs/context-format.md +60 -0
- package/skills/init-capability-evolution/SKILL.md +1 -0
- package/skills/loop-agent/SKILL.md +11 -9
- package/skills/loop-agent/references/command-reference.md +28 -15
- package/skills/loop-agent/references/docs-converge.md +126 -0
- package/skills/loop-agent/references/harness-policy.md +7 -7
- package/skills/loop-agent/references/hybrid-dag.md +13 -15
- package/skills/loop-agent/references/long-running-loop.md +4 -6
- package/skills/loop-agent/references/multi-worktree.md +6 -6
- package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +14 -11
- package/skills/loop-agent/references/verification-and-failure-handling.md +8 -3
- package/skills/using-git-worktrees/SKILL.md +215 -0
- package/dist/commands/cursor-worker.js +0 -43
- package/dist/cursor-worker-entry.js +0 -8
- package/dist/executors/cursor-artifacts.js +0 -33
- package/dist/executors/cursor-execution-log.js +0 -81
- package/dist/executors/cursor-executor-artifacts.js +0 -134
- package/dist/executors/cursor-run.js +0 -115
- package/dist/executors/cursor-tool.js +0 -94
- package/dist/executors/cursor-worker-client.js +0 -223
- package/dist/executors/cursor-worker-protocol.js +0 -18
- package/dist/executors/cursor-worker-server.js +0 -54
- package/dist/executors/cursor-worker.js +0 -3
- package/dist/executors/cursor.js +0 -6
- package/dist/executors/dag-cursor-executor.js +0 -87
- package/dist/workflows/loop/actions/cursor-fix.js +0 -191
- package/dist/workflows/loop/policy/cursor-fix-policy.js +0 -31
- package/docs/cursor-executor-usage.md +0 -25
- package/docs/dynamic-workflow-dag-engine-roadmap.md +0 -1749
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { controllerIdentityExpectationFailure, resolveControllerIdentity, } from "../loop-agent/loop-agent-client.js";
|
|
4
|
+
import { buildGlobalSnapshot } from "../observability/read-model.js";
|
|
5
|
+
import { createSnapshotStore } from "../observability/snapshot-store.js";
|
|
6
|
+
import { preflightTargetRepo } from "../preflight.js";
|
|
7
|
+
import { noopProgressReporter } from "../progress-reporter.js";
|
|
8
|
+
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
9
|
+
import { writeMorningReport } from "../report/morning-report.js";
|
|
10
|
+
import { buildBatchRunId, runReadyTasks } from "../runner/run-ready.js";
|
|
11
|
+
import { validateFeatureTaskGraph } from "../task-graph/validate.js";
|
|
12
|
+
import { reviewFeature } from "./review.js";
|
|
13
|
+
import { finalizeGitTask, startGitTransaction } from "../delivery/git-transaction.js";
|
|
14
|
+
export async function runFeature(options) {
|
|
15
|
+
const featureDir = path.resolve(options.featureDir);
|
|
16
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
17
|
+
const now = options.now ?? new Date();
|
|
18
|
+
const batchRunId = options.batchRunId ?? buildBatchRunId(now);
|
|
19
|
+
const progress = options.progress ?? noopProgressReporter;
|
|
20
|
+
const dependencies = { ...defaultDependencies, ...options.dependencies };
|
|
21
|
+
const steps = [];
|
|
22
|
+
const requestedLimit = options.limit ?? 1;
|
|
23
|
+
const checkpointMode = options.gitMode === "checkpoint";
|
|
24
|
+
const limitApplied = Number.isInteger(requestedLimit) && requestedLimit > 0
|
|
25
|
+
? checkpointMode ? requestedLimit : Math.min(requestedLimit, 1)
|
|
26
|
+
: 1;
|
|
27
|
+
let featureId = path.basename(featureDir);
|
|
28
|
+
let before;
|
|
29
|
+
let batch;
|
|
30
|
+
let gitTransaction;
|
|
31
|
+
const gitTasks = [];
|
|
32
|
+
const expectedArtifacts = expectedArtifactPaths(repoRoot, batchRunId);
|
|
33
|
+
let controllerIdentity;
|
|
34
|
+
try {
|
|
35
|
+
controllerIdentity = resolveControllerIdentity(options.client, options.controllerIdentity);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return failedResult({
|
|
39
|
+
featureId,
|
|
40
|
+
mode: options.dryRun ? "dry-run" : "executed",
|
|
41
|
+
steps: [{ id: "preflight", status: "failed", durationMs: 0, message: errorMessage(error) }],
|
|
42
|
+
limitApplied,
|
|
43
|
+
code: "controller-identity-client-mismatch",
|
|
44
|
+
message: errorMessage(error),
|
|
45
|
+
evidence: [],
|
|
46
|
+
expectedArtifacts,
|
|
47
|
+
controllerIdentity: options.controllerIdentity,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const identityFailure = controllerIdentityExpectationFailure(controllerIdentity, options.controllerExpectation);
|
|
51
|
+
if (identityFailure) {
|
|
52
|
+
return failedResult({
|
|
53
|
+
featureId,
|
|
54
|
+
mode: options.dryRun ? "dry-run" : "executed",
|
|
55
|
+
steps: [{ id: "preflight", status: "failed", durationMs: 0, message: identityFailure.message }],
|
|
56
|
+
limitApplied,
|
|
57
|
+
code: identityFailure.code,
|
|
58
|
+
message: identityFailure.message,
|
|
59
|
+
evidence: [],
|
|
60
|
+
expectedArtifacts,
|
|
61
|
+
controllerIdentity,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
progress.step("feature validate");
|
|
65
|
+
const validation = await timedStep(steps, "validate", async () => dependencies.validateFeature(featureDir));
|
|
66
|
+
if (!validation.value?.ok) {
|
|
67
|
+
markLastStepFailed(steps, "Feature validation failed");
|
|
68
|
+
const errors = validation.value?.errors ?? [];
|
|
69
|
+
return failedResult({
|
|
70
|
+
featureId: validation.value?.featureId ?? featureId,
|
|
71
|
+
mode: options.dryRun ? "dry-run" : "executed",
|
|
72
|
+
steps,
|
|
73
|
+
limitApplied,
|
|
74
|
+
code: "feature-validation-failed",
|
|
75
|
+
message: errors.map((error) => `${error.code}: ${error.message}`).join("; ") || validation.error || "Feature validation failed",
|
|
76
|
+
evidence: errors.flatMap((error) => error.path ? [error.path] : []),
|
|
77
|
+
nextCommand: `agent-worker task validate-feature ${JSON.stringify(featureDir)}`,
|
|
78
|
+
expectedArtifacts,
|
|
79
|
+
controllerIdentity,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
featureId = validation.value.featureId;
|
|
83
|
+
if (options.dryRun)
|
|
84
|
+
steps.push(planned("preflight", "target repo preflight"));
|
|
85
|
+
if (!options.dryRun) {
|
|
86
|
+
progress.step("target repo preflight");
|
|
87
|
+
const preflight = await timedStep(steps, "preflight", async () => dependencies.preflight({
|
|
88
|
+
repoRoot,
|
|
89
|
+
client: options.client,
|
|
90
|
+
runCheckRepo: options.runCheckRepo,
|
|
91
|
+
...(options.checkRepoCommand ? { checkRepoCommand: options.checkRepoCommand } : {}),
|
|
92
|
+
...(options.controllerExpectation ? { expectation: options.controllerExpectation } : {}),
|
|
93
|
+
}));
|
|
94
|
+
if (!preflight.value?.ok) {
|
|
95
|
+
markLastStepFailed(steps, preflight.value && !preflight.value.ok ? preflight.value.message : preflight.error ?? "target repo preflight failed");
|
|
96
|
+
return preflightFailure({ featureId, repoRoot, steps, limitApplied, preflight: preflight.value, fallbackMessage: preflight.error, expectedArtifacts,
|
|
97
|
+
controllerIdentity: preflight.value?.controllerIdentity ?? controllerIdentity });
|
|
98
|
+
}
|
|
99
|
+
controllerIdentity = preflight.value.controllerIdentity ?? controllerIdentity;
|
|
100
|
+
steps.at(-1).artifacts = preflight.value.commands.map((command) => command.result.artifacts.resultPath);
|
|
101
|
+
}
|
|
102
|
+
if (checkpointMode) {
|
|
103
|
+
const canonicalRepo = await realpath(repoRoot).catch(() => repoRoot);
|
|
104
|
+
const canonicalFeature = await realpath(featureDir).catch(() => featureDir);
|
|
105
|
+
const featureRelative = path.relative(canonicalRepo, canonicalFeature);
|
|
106
|
+
if (featureRelative.startsWith("..") || path.isAbsolute(featureRelative)) {
|
|
107
|
+
return failedResult({ featureId, mode: options.dryRun ? "dry-run" : "executed", steps, limitApplied, code: "git-feature-repo-mismatch", message: "Feature Packet must be inside the Git target repo for checkpoint mode", evidence: [featureDir, repoRoot], expectedArtifacts,
|
|
108
|
+
controllerIdentity });
|
|
109
|
+
}
|
|
110
|
+
if (options.dryRun) {
|
|
111
|
+
steps.push(planned("git-preflight", `create or reuse agent/${featureId.toLowerCase()} and checkpoint each task`));
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const gitStep = await timedStep(steps, "git-preflight", async () => dependencies.startGit({ repoRoot, featureId, now }));
|
|
115
|
+
if (!gitStep.value)
|
|
116
|
+
return failedResult({ featureId, mode: "executed", steps, limitApplied, code: "git-preflight-failed", message: gitStep.error ?? "Git transaction preflight failed", evidence: [], expectedArtifacts,
|
|
117
|
+
controllerIdentity });
|
|
118
|
+
gitTransaction = gitStep.value;
|
|
119
|
+
steps.at(-1).artifacts = [gitTransaction.recordPath];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const beforeStep = await timedStep(steps, "review-before", async () => dependencies.review({ featureDir, repoRoot }));
|
|
123
|
+
if (!beforeStep.value) {
|
|
124
|
+
return failedResult({ featureId, mode: options.dryRun ? "dry-run" : "executed", steps, limitApplied, code: "feature-review-failed", message: beforeStep.error ?? "Feature review failed", evidence: [], expectedArtifacts,
|
|
125
|
+
controllerIdentity });
|
|
126
|
+
}
|
|
127
|
+
before = beforeStep.value;
|
|
128
|
+
const readyTasks = before.tasks.filter((task) => task.status === "Ready").map((task) => task.taskId);
|
|
129
|
+
if (options.dryRun) {
|
|
130
|
+
steps.push({ ...planned("run-ready", readyTasks.length > 0 ? `execute at most ${limitApplied} Ready task` : "no Ready task"), artifacts: [expectedArtifacts.batchRun] }, { ...planned("morning-report", "refresh morning report"), artifacts: [expectedArtifacts.morningReport] }, { ...planned("observe-snapshot", "refresh Observe snapshot"), artifacts: [expectedArtifacts.observeSnapshot] }, planned("review-after", "derive final Feature review"));
|
|
131
|
+
return {
|
|
132
|
+
schemaVersion: 1,
|
|
133
|
+
featureId,
|
|
134
|
+
mode: "dry-run",
|
|
135
|
+
status: readyTasks.length > 0 ? "completed" : "no-ready",
|
|
136
|
+
noReadyReason: readyTasks.length > 0 ? null : classifyNoReady(before),
|
|
137
|
+
statusBefore: before.status,
|
|
138
|
+
statusAfter: before.status,
|
|
139
|
+
steps,
|
|
140
|
+
readyTasks,
|
|
141
|
+
executedTasks: [],
|
|
142
|
+
artifacts: { batchRun: null, morningReport: null, observeSnapshot: null, ...(checkpointMode ? { gitTransaction: expectedArtifacts.gitTransaction ?? null } : {}) },
|
|
143
|
+
expectedArtifacts,
|
|
144
|
+
reviewBefore: before,
|
|
145
|
+
reviewAfter: before,
|
|
146
|
+
nextActions: before.nextAction ? [before.nextAction] : [],
|
|
147
|
+
batch: null,
|
|
148
|
+
gitTasks,
|
|
149
|
+
limitApplied,
|
|
150
|
+
controllerIdentity,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (readyTasks.length > 0 && limitApplied > 0) {
|
|
154
|
+
progress.batch(`feature ${featureId}: executing ${Math.min(readyTasks.length, limitApplied)} of ${readyTasks.length} Ready task(s)`);
|
|
155
|
+
const runStep = await timedStep(steps, "run-ready", async () => dependencies.runReady({
|
|
156
|
+
repoRoot,
|
|
157
|
+
featureDir,
|
|
158
|
+
client: options.client,
|
|
159
|
+
limit: limitApplied,
|
|
160
|
+
batchRunId,
|
|
161
|
+
now,
|
|
162
|
+
progress,
|
|
163
|
+
skipTaskPreflight: true,
|
|
164
|
+
...(options.piModel ? { piModel: options.piModel } : {}),
|
|
165
|
+
...(controllerIdentity ? { controllerIdentity } : {}),
|
|
166
|
+
...(options.controllerExpectation ? { controllerExpectation: options.controllerExpectation } : {}),
|
|
167
|
+
...(gitTransaction ? { onTaskFinalized: async (task) => {
|
|
168
|
+
const gitResult = await dependencies.finalizeGit(gitTransaction, { ...task, keepFailedDiff: options.keepFailedDiff, now });
|
|
169
|
+
gitTasks.push(gitResult);
|
|
170
|
+
if (gitResult.status === "kept-failed-diff")
|
|
171
|
+
throw new Error(`git-keep-failed-diff:${gitResult.artifactDir}`);
|
|
172
|
+
} } : {}),
|
|
173
|
+
}));
|
|
174
|
+
batch = runStep.value;
|
|
175
|
+
if (!batch) {
|
|
176
|
+
const kept = runStep.error?.startsWith("git-keep-failed-diff:") ?? false;
|
|
177
|
+
const failed = failedResult({ featureId, mode: "executed", steps, limitApplied, code: kept ? "git-failed-diff-kept" : "run-ready-failed", message: kept ? "Failed task diff was kept by explicit request; automatic execution stopped" : runStep.error ?? "run-ready failed", evidence: kept ? [runStep.error.slice("git-keep-failed-diff:".length)] : [], before, readyTasks, expectedArtifacts,
|
|
178
|
+
controllerIdentity });
|
|
179
|
+
return kept ? { ...failed, status: "needs-action", gitTasks, artifacts: { ...failed.artifacts, gitTransaction: gitTransaction?.recordPath ?? null } } : failed;
|
|
180
|
+
}
|
|
181
|
+
steps.at(-1).artifacts = [batch.batchRunPath];
|
|
182
|
+
if (batch.status === "failed" || batch.tasks.some((task) => task.status === "failed")) {
|
|
183
|
+
markLastStepFailed(steps, batch.status === "failed" ? "run-ready completed with run or record errors" : "one or more tasks failed");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
steps.push({ id: "run-ready", status: "skipped", durationMs: 0, message: "no Ready task" });
|
|
188
|
+
}
|
|
189
|
+
const morningReportPath = path.join(getTaskPoolRoot(repoRoot), "reports", "morning-report.md");
|
|
190
|
+
progress.step("refresh morning report");
|
|
191
|
+
const report = await timedStep(steps, "morning-report", async () => dependencies.writeReport({ repoRoot, ...(batch ? { batchRunId: batch.batchRunId } : {}), outputPath: morningReportPath }));
|
|
192
|
+
if (!report.value)
|
|
193
|
+
return failedResult({ featureId, mode: "executed", steps, limitApplied, code: "morning-report-failed", message: report.error ?? "morning report failed", evidence: [], before, readyTasks, batch, expectedArtifacts,
|
|
194
|
+
controllerIdentity });
|
|
195
|
+
steps.at(-1).artifacts = [report.value];
|
|
196
|
+
const snapshotPath = path.join(getTaskPoolRoot(repoRoot), "observability", "snapshot.json");
|
|
197
|
+
progress.step("refresh Observe snapshot");
|
|
198
|
+
const snapshot = await timedStep(steps, "observe-snapshot", async () => {
|
|
199
|
+
const value = await dependencies.buildSnapshot({ repoRoot });
|
|
200
|
+
await dependencies.writeSnapshot(snapshotPath, value);
|
|
201
|
+
return snapshotPath;
|
|
202
|
+
});
|
|
203
|
+
if (!snapshot.value)
|
|
204
|
+
return failedResult({ featureId, mode: "executed", steps, limitApplied, code: "observe-snapshot-failed", message: snapshot.error ?? "Observe snapshot failed", evidence: [report.value], before, readyTasks, batch, artifacts: { morningReport: report.value }, expectedArtifacts,
|
|
205
|
+
controllerIdentity });
|
|
206
|
+
steps.at(-1).artifacts = [snapshot.value];
|
|
207
|
+
progress.step("derive final Feature review");
|
|
208
|
+
const after = await timedStep(steps, "review-after", async () => dependencies.review({ featureDir, repoRoot }));
|
|
209
|
+
if (!after.value)
|
|
210
|
+
return failedResult({ featureId, mode: "executed", steps, limitApplied, code: "feature-review-failed", message: after.error ?? "final Feature review failed", evidence: [report.value, snapshot.value], before, readyTasks, batch, artifacts: { morningReport: report.value, observeSnapshot: snapshot.value }, expectedArtifacts,
|
|
211
|
+
controllerIdentity });
|
|
212
|
+
const taskFailures = batch?.tasks.filter((task) => task.status === "failed") ?? [];
|
|
213
|
+
const runInfrastructureFailed = batch?.status === "failed";
|
|
214
|
+
const executionError = runInfrastructureFailed
|
|
215
|
+
? { code: "run-ready-failed", message: "run-ready completed with run or record errors", evidence: batch ? [batch.batchRunPath] : [] }
|
|
216
|
+
: taskFailures.length > 0
|
|
217
|
+
? { code: "task-failures", message: `${taskFailures.length} task(s) failed`, evidence: taskFailures.flatMap((task) => task.runRecordPath ? [task.runRecordPath] : []) }
|
|
218
|
+
: undefined;
|
|
219
|
+
return {
|
|
220
|
+
schemaVersion: 1,
|
|
221
|
+
featureId,
|
|
222
|
+
mode: "executed",
|
|
223
|
+
status: readyTasks.length === 0 ? "no-ready" : runInfrastructureFailed ? "failed" : taskFailures.length > 0 ? "needs-action" : "completed",
|
|
224
|
+
noReadyReason: readyTasks.length === 0 ? classifyNoReady(after.value) : null,
|
|
225
|
+
statusBefore: before.status,
|
|
226
|
+
statusAfter: after.value.status,
|
|
227
|
+
steps,
|
|
228
|
+
readyTasks,
|
|
229
|
+
executedTasks: batch?.tasks ?? [],
|
|
230
|
+
artifacts: { batchRun: batch?.batchRunPath ?? null, morningReport: report.value, observeSnapshot: snapshot.value, ...(gitTransaction ? { gitTransaction: gitTransaction.recordPath } : {}) },
|
|
231
|
+
expectedArtifacts,
|
|
232
|
+
reviewBefore: before,
|
|
233
|
+
reviewAfter: after.value,
|
|
234
|
+
nextActions: after.value.nextAction ? [after.value.nextAction] : [],
|
|
235
|
+
batch: batch ?? null,
|
|
236
|
+
gitTasks,
|
|
237
|
+
...(executionError ? { error: executionError } : {}),
|
|
238
|
+
limitApplied,
|
|
239
|
+
controllerIdentity,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
export function renderFeatureRun(result) {
|
|
243
|
+
const executed = result.executedTasks.length === 0
|
|
244
|
+
? "无"
|
|
245
|
+
: result.executedTasks.map((task) => `${task.taskId}:${task.status}`).join(", ");
|
|
246
|
+
const reason = result.error?.message ?? `状态 ${result.statusBefore} → ${result.statusAfter};执行 ${executed}`;
|
|
247
|
+
const evidence = result.error?.evidence[0] ?? result.artifacts.batchRun ?? result.artifacts.morningReport ?? result.artifacts.observeSnapshot ?? "暂无";
|
|
248
|
+
return [
|
|
249
|
+
`Feature: ${result.featureId}`,
|
|
250
|
+
`结果: ${result.status}`,
|
|
251
|
+
`原因: ${reason}`,
|
|
252
|
+
`下一步: ${result.error?.nextCommand ?? result.nextActions[0]?.command ?? result.nextActions[0]?.label ?? "当前无行动"}`,
|
|
253
|
+
`证据: ${evidence}`,
|
|
254
|
+
].join("\n") + "\n";
|
|
255
|
+
}
|
|
256
|
+
const defaultDependencies = {
|
|
257
|
+
validateFeature: validateFeatureTaskGraph,
|
|
258
|
+
review: reviewFeature,
|
|
259
|
+
preflight: preflightTargetRepo,
|
|
260
|
+
runReady: runReadyTasks,
|
|
261
|
+
writeReport: writeMorningReport,
|
|
262
|
+
buildSnapshot: buildGlobalSnapshot,
|
|
263
|
+
writeSnapshot: async (snapshotPath, snapshot) => {
|
|
264
|
+
const store = createSnapshotStore(snapshotPath);
|
|
265
|
+
await store.write(snapshot);
|
|
266
|
+
const persisted = await store.read();
|
|
267
|
+
if (!persisted || JSON.stringify(persisted) !== JSON.stringify(snapshot)) {
|
|
268
|
+
throw new Error(`snapshot was not written: ${snapshotPath}`);
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
startGit: startGitTransaction,
|
|
272
|
+
finalizeGit: finalizeGitTask,
|
|
273
|
+
};
|
|
274
|
+
async function timedStep(steps, id, run) {
|
|
275
|
+
const startedAt = Date.now();
|
|
276
|
+
try {
|
|
277
|
+
const value = await run();
|
|
278
|
+
steps.push({ id, status: "completed", durationMs: Date.now() - startedAt });
|
|
279
|
+
return { value };
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
283
|
+
steps.push({ id, status: "failed", durationMs: Date.now() - startedAt, message: detail });
|
|
284
|
+
return { error: detail };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function planned(id, message) {
|
|
288
|
+
return { id, status: "planned", durationMs: 0, message };
|
|
289
|
+
}
|
|
290
|
+
function markLastStepFailed(steps, message) {
|
|
291
|
+
const step = steps.at(-1);
|
|
292
|
+
if (!step)
|
|
293
|
+
return;
|
|
294
|
+
step.status = "failed";
|
|
295
|
+
step.message = message;
|
|
296
|
+
}
|
|
297
|
+
function failedResult(input) {
|
|
298
|
+
return {
|
|
299
|
+
schemaVersion: 1,
|
|
300
|
+
featureId: input.featureId,
|
|
301
|
+
mode: input.mode,
|
|
302
|
+
status: "failed",
|
|
303
|
+
noReadyReason: null,
|
|
304
|
+
statusBefore: input.before?.status ?? "unknown",
|
|
305
|
+
statusAfter: input.before?.status ?? "unknown",
|
|
306
|
+
steps: input.steps,
|
|
307
|
+
readyTasks: input.readyTasks ?? [],
|
|
308
|
+
executedTasks: input.batch?.tasks ?? [],
|
|
309
|
+
artifacts: {
|
|
310
|
+
batchRun: input.batch?.batchRunPath ?? null,
|
|
311
|
+
morningReport: input.artifacts?.morningReport ?? null,
|
|
312
|
+
observeSnapshot: input.artifacts?.observeSnapshot ?? null,
|
|
313
|
+
},
|
|
314
|
+
expectedArtifacts: input.expectedArtifacts,
|
|
315
|
+
reviewBefore: input.before ?? null,
|
|
316
|
+
reviewAfter: input.before ?? null,
|
|
317
|
+
nextActions: input.before?.nextAction ? [input.before.nextAction] : [],
|
|
318
|
+
batch: input.batch ?? null,
|
|
319
|
+
error: { code: input.code, message: input.message, evidence: input.evidence, ...(input.nextCommand ? { nextCommand: input.nextCommand } : {}) },
|
|
320
|
+
limitApplied: input.limitApplied,
|
|
321
|
+
controllerIdentity: input.controllerIdentity,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function expectedArtifactPaths(repoRoot, batchRunId) {
|
|
325
|
+
const taskPoolRoot = getTaskPoolRoot(repoRoot);
|
|
326
|
+
return {
|
|
327
|
+
batchRun: path.join(taskPoolRoot, "artifacts", batchRunId, "batch-run.json"),
|
|
328
|
+
morningReport: path.join(taskPoolRoot, "reports", "morning-report.md"),
|
|
329
|
+
observeSnapshot: path.join(taskPoolRoot, "observability", "snapshot.json"),
|
|
330
|
+
gitTransaction: path.join(taskPoolRoot, "artifacts", "features", "<feature-id>", "git", "transaction.json"),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function classifyNoReady(review) {
|
|
334
|
+
if (review.tasks.length === 0)
|
|
335
|
+
return "empty";
|
|
336
|
+
if (review.status === "closed")
|
|
337
|
+
return "closed";
|
|
338
|
+
if (review.status === "deliverable")
|
|
339
|
+
return "deliverable";
|
|
340
|
+
if (review.status === "awaiting_qa")
|
|
341
|
+
return "awaiting-qa";
|
|
342
|
+
if (review.blockingItems.some((item) => item.type === "blocked_task"))
|
|
343
|
+
return "dependency-blocked";
|
|
344
|
+
if (review.status === "needs_action")
|
|
345
|
+
return "needs-action";
|
|
346
|
+
return "other";
|
|
347
|
+
}
|
|
348
|
+
function preflightFailure(input) {
|
|
349
|
+
const failed = input.preflight && !input.preflight.ok ? input.preflight : undefined;
|
|
350
|
+
return failedResult({
|
|
351
|
+
featureId: input.featureId,
|
|
352
|
+
mode: "executed",
|
|
353
|
+
steps: input.steps,
|
|
354
|
+
limitApplied: input.limitApplied,
|
|
355
|
+
code: failed?.code ?? "preflight-failed",
|
|
356
|
+
message: failed?.message ?? input.fallbackMessage ?? "target repo preflight failed",
|
|
357
|
+
evidence: failed?.commands.flatMap((command) => [command.result.artifacts.resultPath]) ?? [],
|
|
358
|
+
nextCommand: `cd ${JSON.stringify(input.repoRoot)} && loop-agent doctor`,
|
|
359
|
+
expectedArtifacts: input.expectedArtifacts,
|
|
360
|
+
controllerIdentity: failed?.controllerIdentity ?? input.controllerIdentity,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
function errorMessage(error) {
|
|
364
|
+
return error instanceof Error ? error.message : String(error);
|
|
365
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { cp, readFile, realpath, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { appendTaskPoolEvent, getEventsJsonlPath, getTaskStatePath, readTaskPoolState, writeTaskPoolState } from "../pool/run-store.js";
|
|
6
|
+
import { taskGraphSpecSchema } from "../task-graph/task-graph-schema.js";
|
|
7
|
+
import { validateFeatureTaskGraph } from "../task-graph/validate.js";
|
|
8
|
+
import { taskSpecSchema } from "../task-spec/schema.js";
|
|
9
|
+
import { assertSafeRuntimeId, assertSafeTaskFile, followUpDir } from "./paths.js";
|
|
10
|
+
import { followUpApprovalSchema, followUpDraftSchema, followUpEvidenceSchema } from "./schema.js";
|
|
11
|
+
import { cleanupPath, hashFeaturePacket, readFollowUpIndex, repoRef, resolveRepoFile, sha256File, withFollowUpLock, writeFollowUpIndex, writeJsonAtomic, writeTextAtomic } from "./store.js";
|
|
12
|
+
export async function approveFollowUp(input) {
|
|
13
|
+
if (!input.owner.trim())
|
|
14
|
+
throw new Error("approve-followup requires a non-empty owner");
|
|
15
|
+
assertSafeRuntimeId(input.followUpId, "followUpId");
|
|
16
|
+
const repoRoot = path.resolve(input.repoRoot);
|
|
17
|
+
const featureDir = path.resolve(input.featureDir);
|
|
18
|
+
const graphPath = path.join(featureDir, "tasks", "task-graph.yaml");
|
|
19
|
+
const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(graphPath, "utf-8")));
|
|
20
|
+
const featureId = graph.feature_id;
|
|
21
|
+
assertSafeRuntimeId(featureId, "featureId");
|
|
22
|
+
if (input.dryRun)
|
|
23
|
+
return prepareApproval({ ...input, repoRoot, featureDir, featureId });
|
|
24
|
+
return withFollowUpLock(repoRoot, featureId, async () => executeApproval({ ...input, repoRoot, featureDir, featureId }));
|
|
25
|
+
}
|
|
26
|
+
async function prepareApproval(input) {
|
|
27
|
+
const loaded = await loadApprovalInputs(input);
|
|
28
|
+
if (loaded.existingApproval)
|
|
29
|
+
return resultFromApproval(loaded.existingApproval, input.repoRoot, loaded.approvalPath, true);
|
|
30
|
+
await assertApprovalPreconditions(loaded);
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: 1,
|
|
33
|
+
status: "planned",
|
|
34
|
+
idempotent: false,
|
|
35
|
+
followUpId: loaded.draft.follow_up_id,
|
|
36
|
+
proposedTaskId: loaded.draft.proposed_task_id,
|
|
37
|
+
taskSpecPath: repoRef(input.repoRoot, loaded.taskSpecPath),
|
|
38
|
+
graphPath: repoRef(input.repoRoot, loaded.graphPath),
|
|
39
|
+
statePath: repoRef(input.repoRoot, loaded.statePath),
|
|
40
|
+
approvalPath: repoRef(input.repoRoot, loaded.approvalPath),
|
|
41
|
+
eventPath: repoRef(input.repoRoot, getEventsJsonlPath(input.repoRoot)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function executeApproval(input) {
|
|
45
|
+
const loaded = await loadApprovalInputs(input);
|
|
46
|
+
if (loaded.existingApproval)
|
|
47
|
+
return resultFromApproval(loaded.existingApproval, input.repoRoot, loaded.approvalPath, true);
|
|
48
|
+
await assertApprovalPreconditions(loaded);
|
|
49
|
+
const beforeGraphText = await readFile(loaded.graphPath, "utf-8");
|
|
50
|
+
const beforeGraphHash = await sha256File(loaded.graphPath);
|
|
51
|
+
const stageDir = path.join(path.dirname(input.featureDir), `.followup-stage-${input.followUpId}-${randomUUID()}`);
|
|
52
|
+
await cp(input.featureDir, stageDir, { recursive: true });
|
|
53
|
+
const changedFiles = new Map();
|
|
54
|
+
let stateWritten = false;
|
|
55
|
+
let approvalWritten = false;
|
|
56
|
+
let indexWritten = false;
|
|
57
|
+
const originalIndex = await readFollowUpIndex(input.repoRoot, input.featureId);
|
|
58
|
+
try {
|
|
59
|
+
const stagedGraphPath = path.join(stageDir, "tasks", "task-graph.yaml");
|
|
60
|
+
const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(stagedGraphPath, "utf-8")));
|
|
61
|
+
graph.nodes.push(loaded.draft.proposed_graph_patch.add_node);
|
|
62
|
+
for (const rewire of loaded.draft.proposed_graph_patch.rewire_dependents) {
|
|
63
|
+
const node = graph.nodes.find((candidate) => candidate.id === rewire.task_id);
|
|
64
|
+
if (!node)
|
|
65
|
+
throw new Error(`rewire target is missing: ${rewire.task_id}`);
|
|
66
|
+
assertSafeTaskFile(node.task, `rewire task file for ${node.id}`);
|
|
67
|
+
node.depends_on = node.depends_on.map((dependency) => dependency === rewire.from_dependency ? rewire.to_dependency : dependency);
|
|
68
|
+
const stagedTaskPath = path.join(stageDir, "tasks", node.task);
|
|
69
|
+
const task = taskSpecSchema.parse(YAML.parse(await readFile(stagedTaskPath, "utf-8")));
|
|
70
|
+
task.depends_on = [...node.depends_on];
|
|
71
|
+
await writeTextAtomic(stagedTaskPath, YAML.stringify(task));
|
|
72
|
+
}
|
|
73
|
+
await writeTextAtomic(path.join(stageDir, "tasks", `${loaded.draft.proposed_task_id}.yaml`), YAML.stringify(loaded.draft.proposed_task_spec));
|
|
74
|
+
await writeTextAtomic(stagedGraphPath, YAML.stringify(graph));
|
|
75
|
+
const validation = await validateFeatureTaskGraph(stageDir);
|
|
76
|
+
if (!validation.ok)
|
|
77
|
+
throw new Error(`staged Feature validation failed: ${validation.errors.map((error) => error.code).join(", ")}`);
|
|
78
|
+
const afterGraphHash = await sha256File(stagedGraphPath);
|
|
79
|
+
const stagedRelativeFiles = [
|
|
80
|
+
path.join("tasks", `${loaded.draft.proposed_task_id}.yaml`),
|
|
81
|
+
"tasks/task-graph.yaml",
|
|
82
|
+
...loaded.draft.proposed_graph_patch.rewire_dependents.map((rewire) => {
|
|
83
|
+
const node = graph.nodes.find((candidate) => candidate.id === rewire.task_id);
|
|
84
|
+
return path.join("tasks", node.task);
|
|
85
|
+
}),
|
|
86
|
+
];
|
|
87
|
+
for (const relative of [...new Set(stagedRelativeFiles)]) {
|
|
88
|
+
const target = path.join(input.featureDir, relative);
|
|
89
|
+
changedFiles.set(target, await readOptional(target));
|
|
90
|
+
const temp = path.join(path.dirname(target), `.${path.basename(target)}.${randomUUID()}.tmp`);
|
|
91
|
+
try {
|
|
92
|
+
await writeFile(temp, await readFile(path.join(stageDir, relative)));
|
|
93
|
+
await input.faultInjector?.("before-packet-rename", relative);
|
|
94
|
+
await rename(temp, target);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await unlink(temp).catch(() => { });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await input.faultInjector?.("before-state-write");
|
|
101
|
+
await writeTaskPoolState(input.repoRoot, {
|
|
102
|
+
taskId: loaded.draft.proposed_task_id,
|
|
103
|
+
status: "Ready",
|
|
104
|
+
updatedAt: (input.now ?? new Date()).toISOString(),
|
|
105
|
+
});
|
|
106
|
+
stateWritten = true;
|
|
107
|
+
const approvedAt = (input.now ?? new Date()).toISOString();
|
|
108
|
+
const approval = {
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
followUpId: loaded.draft.follow_up_id,
|
|
111
|
+
proposedTaskId: loaded.draft.proposed_task_id,
|
|
112
|
+
owner: input.owner,
|
|
113
|
+
approvedAt,
|
|
114
|
+
draftHash: await sha256File(loaded.draftPath),
|
|
115
|
+
beforeGraphHash,
|
|
116
|
+
afterGraphHash,
|
|
117
|
+
taskSpecPath: repoRef(input.repoRoot, loaded.taskSpecPath),
|
|
118
|
+
graphPath: repoRef(input.repoRoot, loaded.graphPath),
|
|
119
|
+
statePath: repoRef(input.repoRoot, loaded.statePath),
|
|
120
|
+
eventPath: repoRef(input.repoRoot, getEventsJsonlPath(input.repoRoot)),
|
|
121
|
+
};
|
|
122
|
+
followUpApprovalSchema.parse(approval);
|
|
123
|
+
await input.faultInjector?.("before-approval-write");
|
|
124
|
+
await writeJsonAtomic(loaded.approvalPath, approval);
|
|
125
|
+
approvalWritten = true;
|
|
126
|
+
const index = structuredClone(originalIndex);
|
|
127
|
+
const entry = index.entries.find((candidate) => candidate.followUpId === input.followUpId);
|
|
128
|
+
entry.status = "Approved";
|
|
129
|
+
entry.approvalPath = repoRef(input.repoRoot, loaded.approvalPath);
|
|
130
|
+
entry.approvedAt = approvedAt;
|
|
131
|
+
await input.faultInjector?.("before-index-write");
|
|
132
|
+
await writeFollowUpIndex(input.repoRoot, index);
|
|
133
|
+
indexWritten = true;
|
|
134
|
+
await input.faultInjector?.("before-event-append");
|
|
135
|
+
await appendTaskPoolEvent(input.repoRoot, {
|
|
136
|
+
schemaVersion: 1,
|
|
137
|
+
at: approvedAt,
|
|
138
|
+
type: "follow-up-approved",
|
|
139
|
+
featureId: input.featureId,
|
|
140
|
+
followUpId: input.followUpId,
|
|
141
|
+
proposedTaskId: loaded.draft.proposed_task_id,
|
|
142
|
+
parentTaskId: loaded.draft.lineage.parent_task_id,
|
|
143
|
+
workerRunId: loaded.draft.generated_from.worker_run_id,
|
|
144
|
+
owner: input.owner,
|
|
145
|
+
draftHash: approval.draftHash,
|
|
146
|
+
beforeGraphHash,
|
|
147
|
+
afterGraphHash,
|
|
148
|
+
artifactRefs: [repoRef(input.repoRoot, loaded.draftPath), repoRef(input.repoRoot, loaded.approvalPath)],
|
|
149
|
+
});
|
|
150
|
+
return resultFromApproval(approval, input.repoRoot, loaded.approvalPath, false);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (indexWritten)
|
|
154
|
+
await writeFollowUpIndex(input.repoRoot, originalIndex).catch(() => { });
|
|
155
|
+
if (approvalWritten)
|
|
156
|
+
await unlink(loaded.approvalPath).catch(() => { });
|
|
157
|
+
if (stateWritten)
|
|
158
|
+
await unlink(loaded.statePath).catch(() => { });
|
|
159
|
+
for (const [target, original] of [...changedFiles.entries()].reverse()) {
|
|
160
|
+
if (original === undefined)
|
|
161
|
+
await unlink(target).catch(() => { });
|
|
162
|
+
else
|
|
163
|
+
await writeTextAtomic(target, original).catch(() => { });
|
|
164
|
+
}
|
|
165
|
+
await writeTextAtomic(loaded.graphPath, beforeGraphText).catch(() => { });
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await cleanupPath(stageDir);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function loadApprovalInputs(input) {
|
|
173
|
+
const index = await readFollowUpIndex(input.repoRoot, input.featureId);
|
|
174
|
+
const entry = index.entries.find((candidate) => candidate.followUpId === input.followUpId);
|
|
175
|
+
if (!entry)
|
|
176
|
+
throw new Error(`follow-up not found: ${input.followUpId}`);
|
|
177
|
+
if (entry.kind !== "TaskDraft" || !entry.proposedTaskId)
|
|
178
|
+
throw new Error(`follow-up is not an approvable TaskDraft: ${entry.kind}`);
|
|
179
|
+
const draftPath = await resolveRepoFile(input.repoRoot, entry.draftPath);
|
|
180
|
+
const approvalPath = path.join(followUpDir(input.repoRoot, input.featureId, input.followUpId), "approval.json");
|
|
181
|
+
const evidencePath = path.join(followUpDir(input.repoRoot, input.featureId, input.followUpId), "evidence.json");
|
|
182
|
+
let existingApproval;
|
|
183
|
+
try {
|
|
184
|
+
existingApproval = followUpApprovalSchema.parse(JSON.parse(await readFile(approvalPath, "utf-8")));
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!isNotFound(error))
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
const draft = followUpDraftSchema.parse(YAML.parse(await readFile(draftPath, "utf-8")));
|
|
191
|
+
for (const [value, label] of [
|
|
192
|
+
[draft.follow_up_id, "draft.followUpId"],
|
|
193
|
+
[draft.proposed_task_id, "draft.proposedTaskId"],
|
|
194
|
+
[draft.generated_from.feature_id, "draft.featureId"],
|
|
195
|
+
[draft.generated_from.task_id, "draft.taskId"],
|
|
196
|
+
[draft.lineage.parent_task_id, "draft.parentTaskId"],
|
|
197
|
+
...draft.proposed_graph_patch.rewire_dependents.flatMap((rewire) => [[rewire.task_id, "rewire.taskId"], [rewire.from_dependency, "rewire.fromDependency"], [rewire.to_dependency, "rewire.toDependency"]]),
|
|
198
|
+
])
|
|
199
|
+
assertSafeRuntimeId(value, label);
|
|
200
|
+
return {
|
|
201
|
+
entry, draft, draftPath, evidencePath, approvalPath, existingApproval,
|
|
202
|
+
taskSpecPath: path.join(input.featureDir, "tasks", `${draft.proposed_task_id}.yaml`),
|
|
203
|
+
graphPath: path.join(input.featureDir, "tasks", "task-graph.yaml"),
|
|
204
|
+
statePath: getTaskStatePath(input.repoRoot, draft.proposed_task_id),
|
|
205
|
+
featureId: input.featureId,
|
|
206
|
+
featureDir: input.featureDir,
|
|
207
|
+
repoRoot: input.repoRoot,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
async function assertApprovalPreconditions(loaded) {
|
|
211
|
+
if (loaded.entry.status !== "Draft" || loaded.draft.status !== "Draft")
|
|
212
|
+
throw new Error(`follow-up is not Draft: ${loaded.entry.status}`);
|
|
213
|
+
if (loaded.draft.generated_from.feature_id !== loaded.featureId)
|
|
214
|
+
throw new Error("follow-up Feature lineage does not match its index");
|
|
215
|
+
if (await sha256File(loaded.draftPath) !== loaded.entry.draftHash)
|
|
216
|
+
throw new Error("follow-up draft hash changed; regenerate and review the draft");
|
|
217
|
+
const evidenceHash = await sha256File(loaded.evidencePath);
|
|
218
|
+
if (evidenceHash !== loaded.entry.evidenceHash || evidenceHash !== loaded.draft.evidence_manifest_hash)
|
|
219
|
+
throw new Error("follow-up evidence manifest changed; regenerate and review the draft");
|
|
220
|
+
const evidence = followUpEvidenceSchema.parse(JSON.parse(await readFile(loaded.evidencePath, "utf-8")));
|
|
221
|
+
if (evidence.followUpId !== loaded.draft.follow_up_id)
|
|
222
|
+
throw new Error("follow-up evidence lineage does not match the draft");
|
|
223
|
+
if (evidence.evidence.map((item) => item.ref).join("\0") !== loaded.draft.evidence_refs.join("\0"))
|
|
224
|
+
throw new Error("follow-up evidence refs do not match the draft");
|
|
225
|
+
for (const item of evidence.evidence) {
|
|
226
|
+
const evidencePath = await realpath(path.resolve(loaded.repoRoot, item.ref));
|
|
227
|
+
const canonicalRoot = await realpath(loaded.repoRoot);
|
|
228
|
+
const relative = path.relative(canonicalRoot, evidencePath).split(path.sep).join("/");
|
|
229
|
+
if (relative !== item.ref || relative.startsWith("..") || path.isAbsolute(relative) || await sha256File(evidencePath) !== item.sha256) {
|
|
230
|
+
throw new Error(`follow-up evidence changed or escaped the repo: ${item.ref}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (await hashFeaturePacket(loaded.featureDir) !== loaded.draft.feature_packet_hash)
|
|
234
|
+
throw new Error("Feature Packet baseline changed; regenerate the follow-up draft");
|
|
235
|
+
if (await readOptional(loaded.taskSpecPath) !== undefined)
|
|
236
|
+
throw new Error(`proposed TaskSpec already exists: ${loaded.draft.proposed_task_id}`);
|
|
237
|
+
const graph = taskGraphSpecSchema.parse(YAML.parse(await readFile(loaded.graphPath, "utf-8")));
|
|
238
|
+
if (graph.nodes.some((node) => node.id === loaded.draft.proposed_task_id))
|
|
239
|
+
throw new Error(`proposed graph node already exists: ${loaded.draft.proposed_task_id}`);
|
|
240
|
+
if (await readTaskPoolState(loaded.repoRoot, loaded.draft.proposed_task_id))
|
|
241
|
+
throw new Error(`Task Pool state already exists: ${loaded.draft.proposed_task_id}`);
|
|
242
|
+
}
|
|
243
|
+
function resultFromApproval(approval, repoRoot, approvalPath, idempotent) {
|
|
244
|
+
return {
|
|
245
|
+
schemaVersion: 1,
|
|
246
|
+
status: "approved",
|
|
247
|
+
idempotent,
|
|
248
|
+
followUpId: approval.followUpId,
|
|
249
|
+
proposedTaskId: approval.proposedTaskId,
|
|
250
|
+
taskSpecPath: approval.taskSpecPath,
|
|
251
|
+
graphPath: approval.graphPath,
|
|
252
|
+
statePath: approval.statePath,
|
|
253
|
+
approvalPath: repoRef(repoRoot, approvalPath),
|
|
254
|
+
eventPath: approval.eventPath,
|
|
255
|
+
approval,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
async function readOptional(filePath) {
|
|
259
|
+
try {
|
|
260
|
+
return await readFile(filePath, "utf-8");
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
if (isNotFound(error))
|
|
264
|
+
return undefined;
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function isNotFound(error) {
|
|
269
|
+
return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
|
|
270
|
+
}
|