@deksden-com/dd-flow-cli 0.9.0-beta.16 → 0.9.0-beta.17
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/CHANGELOG.md +10 -0
- package/dist/build-info.json +4 -4
- package/dist/cli/help.js +2 -1
- package/dist/cli/run-cli.js +9 -6
- package/dist/services/code-checks.js +22 -2
- package/dist/services/eval-snapshots.js +23 -10
- package/dist/services/runs.js +29 -7
- package/dist/services/vnext-code-review.js +1 -1
- package/dist/services/vnext-code.js +9 -4
- package/dist/services/vnext-fanout.js +67 -2
- package/dist/services/vnext-merge.js +17 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.17
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0dd4da3: Verify current-attempt gate retries, deduplicate equivalent checks across gate references, retain archive locations across interrupted and repeated repairs, and align MERGE environment recovery guidance with source repair.
|
|
8
|
+
|
|
9
|
+
Snapshot SQLite consistently with VACUUM INTO and exclude local environment secrets, dependencies and caches from project/workspace snapshots.
|
|
10
|
+
|
|
11
|
+
Reconcile native child failures only against unique registered Work/Session attempts, retain non-restorable incomplete snapshots for failed evals, and expose unexpected merge-lane release failures for safe retry.
|
|
12
|
+
|
|
3
13
|
## 0.9.0-beta.16
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/dist/build-info.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"cli_package": "@deksden-com/dd-flow-cli",
|
|
3
|
-
"cli_version": "0.9.0-beta.
|
|
4
|
-
"cli_commit": "
|
|
5
|
-
"built_at": "2026-09-
|
|
3
|
+
"cli_version": "0.9.0-beta.17",
|
|
4
|
+
"cli_commit": "162dd994d19c0d454a2ad6b7b62abd70c3e848d6",
|
|
5
|
+
"built_at": "2026-09-06T00:16:19.328Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
7
|
"version": "4.0.4",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "ced59c19d0a4062146a1dc4ccb977e10284a2fe4",
|
|
9
9
|
"flow_contract": "dd-flow-canonical-2026-08",
|
|
10
10
|
"repo_root": "/Users/deksden/Documents/_Projects/dd-memorybank",
|
|
11
11
|
"memorybank_root": "/Users/deksden/Documents/_Projects/dd-memorybank/.memory-bank",
|
package/dist/cli/help.js
CHANGED
|
@@ -154,7 +154,7 @@ Usage:
|
|
|
154
154
|
dd-flow run status <RUN-ID|RUN-short-id> --project-root <root> --json
|
|
155
155
|
dd-flow run list --project-root <root> --json
|
|
156
156
|
dd-flow run prepare-vnext-specify --project-root <root> --slug <slug> --json
|
|
157
|
-
dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate) --project-root <root> --output <directory> --json
|
|
157
|
+
dd-flow run snapshot create <RUN-ID|RUN-short-id> (--stage-entry <stage>|--candidate|--incomplete) --project-root <root> --output <directory> --json
|
|
158
158
|
dd-flow run snapshot restore --snapshot <directory> --project-root <prepared-root> --json
|
|
159
159
|
dd-flow run snapshot bootstrap create --project-root <root> --output <directory> --json
|
|
160
160
|
dd-flow run snapshot bootstrap restore --snapshot <directory> --project-root <prepared-root> --json
|
|
@@ -190,6 +190,7 @@ Usage:
|
|
|
190
190
|
dd-flow stage unblock <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --work <WORK-ID> --json
|
|
191
191
|
dd-flow stage fanout status <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
|
|
192
192
|
dd-flow stage fanout dispatch <RUN-ID|RUN-short-id> --project-root <root> --stage <name> --json
|
|
193
|
+
dd-flow stage fanout reconcile <RUN-ID> --project-root <root> --stage <name> --observations-file <native-observations.json> --json
|
|
193
194
|
dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage <name> [--semantic-file <RUN-local-json>] --json
|
|
194
195
|
dd-flow stage finish <RUN-ID|RUN-short-id> --project-root <root> --stage specify (--result-file <RUN-local-specify.json>|--result-stdin) --outcome <specified|failed|cancelled> --json
|
|
195
196
|
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -41,7 +41,7 @@ import { addVnextCodeRepair, finishVnextCode, startVnextCode } from "../services
|
|
|
41
41
|
import { addVnextCodeReviewRepair, finishVnextCodeReview, isVnextCodeReviewRun, startVnextCodeReview } from "../services/vnext-code-review.js";
|
|
42
42
|
import { applyVnextMerge, finishVnextMerge, getVnextMergeRequest, isVnextMergeRun, repairVnextMerge, routeVnextMergeRequest, startVnextMerge } from "../services/vnext-merge.js";
|
|
43
43
|
import { mergeServerStatus, serveMergeRequests, stopMergeServer } from "../services/merge-server.js";
|
|
44
|
-
import { getVnextFanoutStatus } from "../services/vnext-fanout.js";
|
|
44
|
+
import { getVnextFanoutStatus, reconcileVnextFanout } from "../services/vnext-fanout.js";
|
|
45
45
|
import { pauseStageForUser, resumeStageAfterUser } from "../services/stage-pause.js";
|
|
46
46
|
import { blockStageForRuntime, unblockStageAfterRuntimeRepair } from "../services/stage-blocker.js";
|
|
47
47
|
import { classifyCliOperation, requiresExplicitUpgradeAuthorization } from "../services/cli-operation-classifier.js";
|
|
@@ -661,12 +661,14 @@ async function dispatch(args, context, io, scopeProjectRoot = null, classificati
|
|
|
661
661
|
const projectRoot = requiredScopeProjectRoot(scopeProjectRoot, "stage fanout");
|
|
662
662
|
if (action === "status")
|
|
663
663
|
return getVnextFanoutStatus(context, { projectRoot, runId, stage });
|
|
664
|
+
if (action === "reconcile")
|
|
665
|
+
return reconcileVnextFanout(context, { projectRoot, runId, stage, observations: JSON.parse(fs.readFileSync(requiredOption(parsed, "observations-file"), "utf8")) });
|
|
664
666
|
if (action === "dispatch") {
|
|
665
667
|
if (stage === "plan-review")
|
|
666
668
|
return dispatchVnextPlanReview(context, { projectRoot, runId });
|
|
667
669
|
return getVnextFanoutStatus(context, { projectRoot, runId, stage });
|
|
668
670
|
}
|
|
669
|
-
throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch> <RUN> --stage <stage> --project-root <path>", 2);
|
|
671
|
+
throw new AppError("usage", "Use: dd-flow stage fanout <status|dispatch|reconcile> <RUN> --stage <stage> --project-root <path>; reconcile requires --observations-file <native-observations.json>", 2);
|
|
670
672
|
}
|
|
671
673
|
if (family === "run" && command === "capacity") {
|
|
672
674
|
if (requiredPosition(parsed, 0, "capacity action") !== "record")
|
|
@@ -1435,16 +1437,17 @@ function dispatchRun(context, command, parsed) {
|
|
|
1435
1437
|
}
|
|
1436
1438
|
if (action === "create") {
|
|
1437
1439
|
const candidate = parsed.options.has("candidate");
|
|
1440
|
+
const incomplete = parsed.options.has("incomplete");
|
|
1438
1441
|
const stageEntry = optionalOption(parsed, "stage-entry");
|
|
1439
|
-
if (candidate
|
|
1440
|
-
throw new AppError("usage", "Use
|
|
1441
|
-
if (!candidate && stageEntry === undefined)
|
|
1442
|
-
throw new AppError("usage", "Snapshot create requires --candidate or --stage-entry", 2);
|
|
1442
|
+
if (Number(candidate) + Number(incomplete) + Number(stageEntry !== undefined) !== 1)
|
|
1443
|
+
throw new AppError("usage", "Use exactly one of --candidate, --incomplete or --stage-entry", 2);
|
|
1443
1444
|
const input = {
|
|
1444
1445
|
projectRoot: requiredOption(parsed, "project-root"),
|
|
1445
1446
|
runId: requiredPosition(parsed, 1, "run-id"),
|
|
1446
1447
|
output: requiredOption(parsed, "output")
|
|
1447
1448
|
};
|
|
1449
|
+
if (incomplete)
|
|
1450
|
+
return createEvalRunSnapshot(context, { ...input, incomplete: true });
|
|
1448
1451
|
return candidate
|
|
1449
1452
|
? createEvalRunSnapshot(context, { ...input, candidate: true })
|
|
1450
1453
|
: createEvalRunSnapshot(context, { ...input, stageEntry: stageEntry });
|
|
@@ -225,9 +225,14 @@ export function unchangedFinalGateFailures(context, input) { const wanted = new
|
|
|
225
225
|
latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current && receipt.id !== input.retryReceiptId); }
|
|
226
226
|
/** Validate an explicit retry; the caller still executes a fresh real gate and retains a new receipt. */
|
|
227
227
|
export function validateFailedCheckRetry(context, input) {
|
|
228
|
+
if (!input.reason?.trim())
|
|
229
|
+
throw new AppError("retry_check_invalid", "--retry-check requires --reason describing the restored environment", 2);
|
|
228
230
|
const receipt = checkReceipts(context, { projectId: input.projectId, runId: input.runId }).find((item) => item.id === input.receiptId);
|
|
229
231
|
if (!receipt || receipt.scope !== "aggregate" || !["failed", "aborted"].includes(receipt.status))
|
|
230
232
|
throw new AppError("retry_check_invalid", "--retry-check must name a failed or aborted aggregate receipt in this RUN", 2, { check_receipt_id: input.receiptId });
|
|
233
|
+
const relative = path.relative(input.artifactRoot, receipt.receipt_path);
|
|
234
|
+
if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`) || /^try-\d+([/\\]|$)/.test(relative))
|
|
235
|
+
throw new AppError("retry_check_invalid", "--retry-check must belong to this current stage attempt, not an archived or different stage", 2, { check_receipt_id: input.receiptId });
|
|
231
236
|
const declared = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)]));
|
|
232
237
|
if (!receipt.check_refs.some((ref) => declared.has(ref)))
|
|
233
238
|
throw new AppError("retry_check_invalid", "--retry-check does not belong to this stage gate", 2, { check_receipt_id: input.receiptId, check_refs: receipt.check_refs });
|
|
@@ -236,12 +241,27 @@ export function validateFailedCheckRetry(context, input) {
|
|
|
236
241
|
export function checkReceipts(context, input) {
|
|
237
242
|
const where = input.workId ? "project_id = ? AND run_id = ? AND work_id = ?" : "project_id = ? AND run_id = ?";
|
|
238
243
|
const params = input.workId ? [input.projectId, input.runId, input.workId] : [input.projectId, input.runId];
|
|
239
|
-
return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []),
|
|
244
|
+
return context.db.all(`SELECT id, declaration_id, check_refs_json, gate, scope, work_id, command, input_hash, verification_epoch, status, exit_code, stdout_path, stderr_path, receipt_path, workspace_fingerprint, before_fingerprint, after_fingerprint, profile_hash, mutation_paths_json, artifacts_json, started_at, finished_at FROM check_receipts WHERE ${where} ORDER BY started_at, id`, params).map((row) => ({ ...row, local_id: row.id.slice(row.id.lastIndexOf("/") + 1), check_refs: parseArray(row.check_refs_json, [row.declaration_id]), mutation_paths: parseArray(row.mutation_paths_json, []), resources: readReceiptResources(row.receipt_path), ...resolvedReceiptArtifacts(row.receipt_path, JSON.parse(row.artifacts_json)) }));
|
|
245
|
+
}
|
|
246
|
+
/** File bytes remain immutable; only the reader projects their current location. */
|
|
247
|
+
function resolvedReceiptArtifacts(receiptPath, artifacts) {
|
|
248
|
+
let original = {};
|
|
249
|
+
try {
|
|
250
|
+
original = JSON.parse(fs.readFileSync(receiptPath, "utf8"));
|
|
251
|
+
}
|
|
252
|
+
catch { /* Historical missing receipts remain visibly incomplete. */ }
|
|
253
|
+
const currentRoot = path.dirname(receiptPath);
|
|
254
|
+
const oldRoot = original.receipt_path ? path.dirname(original.receipt_path) : currentRoot;
|
|
255
|
+
const relocate = (file) => {
|
|
256
|
+
const relative = path.relative(oldRoot, file);
|
|
257
|
+
return !path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`) ? path.join(currentRoot, relative) : file;
|
|
258
|
+
};
|
|
259
|
+
return { completion_path: path.join(currentRoot, "completion.json"), required_artifacts: original.required_artifacts ?? [], artifacts: artifacts.map((artifact) => ({ ...artifact, path: relocate(artifact.path) })) };
|
|
240
260
|
}
|
|
241
261
|
export function workspaceFingerprint(root) { return workspaceState(root).fingerprint; }
|
|
242
262
|
export function workspaceChangedPaths(root) { const status = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); if (status.status !== 0)
|
|
243
263
|
return null; return status.stdout.split("\0").filter(Boolean).map((entry) => entry.slice(3)).filter(Boolean).sort(); }
|
|
244
|
-
function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([
|
|
264
|
+
function deduplicate(checks, commands) { const groups = new Map(); checks.forEach((check, index) => { const artifacts = [...(check.required_artifacts ?? [])].sort(); const ports = [...(check.ports ?? [])].sort(); const key = JSON.stringify([commands[index], artifacts, ports]); const current = groups.get(key); if (current)
|
|
245
265
|
current.declarations.push(check);
|
|
246
266
|
else
|
|
247
267
|
groups.set(key, { gate: check.run_at, command: commands[index], declarations: [check], requiredArtifacts: artifacts, ports }); }); return [...groups.values()]; }
|
|
@@ -19,7 +19,7 @@ export function createEvalBootstrapSnapshot(context, input) {
|
|
|
19
19
|
if (isInside(physicalPath(context.ddFlowHome), physicalPath(output)))
|
|
20
20
|
throw new AppError("snapshot_output_inside_runtime", "Bootstrap snapshot output must be outside DD_FLOW_HOME", 1, { output });
|
|
21
21
|
fs.mkdirSync(output, { recursive: true });
|
|
22
|
-
fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter:
|
|
22
|
+
fs.cpSync(projectRoot, path.join(output, "project"), { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
|
|
23
23
|
const manifest = {
|
|
24
24
|
schema_id: "dd-eval/bootstrap-snapshot@1",
|
|
25
25
|
stage: "specify",
|
|
@@ -55,7 +55,8 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
55
55
|
const project = requireProjectByRoot(context, projectRoot);
|
|
56
56
|
const status = getFlowRunStatus(context, { projectRoot, runId: input.runId });
|
|
57
57
|
assertDedicatedHome(context, project.id, input.runId);
|
|
58
|
-
if (input.
|
|
58
|
+
if (input.incomplete) { /* Forensic evidence is not an accepted or restorable boundary. */ }
|
|
59
|
+
else if (input.candidate)
|
|
59
60
|
assertTerminalCandidate(status.index.stage_runs ?? []);
|
|
60
61
|
else if (input.stageEntry)
|
|
61
62
|
assertStageEntry(status.index.stage_runs ?? [], input.stageEntry);
|
|
@@ -65,7 +66,7 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
65
66
|
const activeChildren = input.candidate
|
|
66
67
|
? context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND status IN ('created', 'running', 'paused')", [project.id, input.runId])?.count ?? 0
|
|
67
68
|
: context.db.get("SELECT COUNT(*) AS count FROM works WHERE project_id = ? AND run_id = ? AND parent_work_id IS NOT NULL AND (status IN ('running', 'paused') OR (status = 'created' AND (? IS NULL OR COALESCE(result_schema, '') NOT LIKE ?)))", [project.id, input.runId, allowedCreatedResultSchema, allowedCreatedResultSchema ? `${allowedCreatedResultSchema}@%` : ""])?.count ?? 0;
|
|
68
|
-
if (activeChildren > 0)
|
|
69
|
+
if (!input.incomplete && activeChildren > 0)
|
|
69
70
|
throw new AppError("snapshot_not_quiescent", `${input.candidate ? "Candidate" : "Stage-entry"} snapshot requires no active child Work`, 1, { run_id: input.runId, active_children: activeChildren });
|
|
70
71
|
const output = path.resolve(input.output);
|
|
71
72
|
if (fs.existsSync(output))
|
|
@@ -75,7 +76,6 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
75
76
|
const workspaceRoot = String(status.run.workspace_root ?? projectRoot);
|
|
76
77
|
if (isInside(physicalPath(projectRoot), physicalPath(output)) || isInside(physicalPath(workspaceRoot), physicalPath(output)))
|
|
77
78
|
throw new AppError("snapshot_output_inside_project", "Snapshot output must be outside the project and RUN workspace trees", 1, { output });
|
|
78
|
-
context.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
79
79
|
fs.mkdirSync(output, { recursive: true });
|
|
80
80
|
fs.cpSync(context.ddFlowHome, path.join(output, "runtime"), {
|
|
81
81
|
recursive: true,
|
|
@@ -83,12 +83,17 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
83
83
|
// Managed checkouts are restored from their Git bundle and workspace
|
|
84
84
|
// payload. Copying their .git indirection into runtime makes the snapshot
|
|
85
85
|
// dependent on the source machine's worktree metadata.
|
|
86
|
-
filter: (source) => !["engines", "checkouts"].includes(path.basename(source))
|
|
86
|
+
filter: (source) => snapshotPayloadAllowed(source) && !["engines", "checkouts"].includes(path.basename(source)) && ![context.db.path, `${context.db.path}-wal`, `${context.db.path}-shm`].includes(source)
|
|
87
87
|
});
|
|
88
|
+
// VACUUM INTO is a consistent SQLite read snapshot, including committed WAL
|
|
89
|
+
// pages. Copying the live database and its WAL separately is not atomic.
|
|
90
|
+
const databaseCopy = path.join(output, "runtime", path.relative(context.ddFlowHome, context.db.path));
|
|
91
|
+
fs.mkdirSync(path.dirname(databaseCopy), { recursive: true });
|
|
92
|
+
context.db.exec(`VACUUM INTO '${databaseCopy.replace(/'/g, "''")}'`);
|
|
88
93
|
fs.cpSync(projectRoot, path.join(output, "project"), {
|
|
89
94
|
recursive: true,
|
|
90
95
|
verbatimSymlinks: true,
|
|
91
|
-
filter:
|
|
96
|
+
filter: snapshotPayloadAllowed
|
|
92
97
|
});
|
|
93
98
|
const projectGit = snapshotProjectGit(projectRoot, output);
|
|
94
99
|
const workspace = snapshotWorkspace(workspaceRoot, projectRoot, output);
|
|
@@ -98,8 +103,9 @@ export function createEvalRunSnapshot(context, input) {
|
|
|
98
103
|
project_id: project.id,
|
|
99
104
|
project_root: projectRoot,
|
|
100
105
|
dd_flow_home: context.ddFlowHome,
|
|
101
|
-
purpose: input.candidate ? "candidate" : "stage_entry",
|
|
102
|
-
stage_entry: input.candidate ? null : input.stageEntry,
|
|
106
|
+
purpose: input.incomplete ? "incomplete" : input.candidate ? "candidate" : "stage_entry",
|
|
107
|
+
stage_entry: input.incomplete || input.candidate ? null : input.stageEntry,
|
|
108
|
+
...(input.incomplete ? { consistency: "sqlite_read_snapshot_files_copied_nonatomically", active_child_work_count: activeChildren, restorable: false } : {}),
|
|
103
109
|
created_at: context.now(),
|
|
104
110
|
runtime_sha256: treeHash(path.join(output, "runtime")),
|
|
105
111
|
project_sha256: treeHash(path.join(output, "project")),
|
|
@@ -121,6 +127,8 @@ function createdWorkSchemaForStage(stage) {
|
|
|
121
127
|
export function restoreEvalRunSnapshot(context, input) {
|
|
122
128
|
const snapshot = path.resolve(input.snapshot);
|
|
123
129
|
const manifest = readManifest(snapshot);
|
|
130
|
+
if (manifest.purpose === "incomplete")
|
|
131
|
+
throw new AppError("snapshot_incomplete_not_restorable", "Incomplete snapshot is forensic evidence, not a stage-entry fixture", 1, { snapshot });
|
|
124
132
|
if (manifest.purpose === "candidate")
|
|
125
133
|
throw new AppError("snapshot_candidate_not_restorable", "A terminal candidate snapshot is evidence, not a stage-entry fixture", 1, { snapshot });
|
|
126
134
|
if (!manifest.stage_entry)
|
|
@@ -285,7 +293,7 @@ function snapshotWorkspace(workspaceRoot, projectRoot, output) {
|
|
|
285
293
|
throw new AppError("snapshot_workspace_invalid", "Feature workspace has no readable HEAD", 1, { workspace_root: workspaceRoot });
|
|
286
294
|
const workspace = path.join(output, "workspace");
|
|
287
295
|
const bundle = path.join(output, "workspace.bundle");
|
|
288
|
-
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter:
|
|
296
|
+
fs.cpSync(workspaceRoot, workspace, { recursive: true, verbatimSymlinks: true, filter: snapshotPayloadAllowed });
|
|
289
297
|
runGit(projectRoot, ["bundle", "create", bundle, branch]);
|
|
290
298
|
return { kind: "git_worktree", source_root: workspaceRoot, branch, base_ref: baseRef, sha256: treeHash(workspace), bundle_sha256: fileHash(bundle) };
|
|
291
299
|
}
|
|
@@ -343,6 +351,11 @@ function runGit(root, args) {
|
|
|
343
351
|
throw new AppError("snapshot_workspace_restore_failed", "Could not restore feature worktree", 1, { stderr: result.stderr.trim(), args });
|
|
344
352
|
}
|
|
345
353
|
function samePath(left, right) { return path.resolve(left) === path.resolve(right); }
|
|
354
|
+
function snapshotPayloadAllowed(source) {
|
|
355
|
+
const name = path.basename(source);
|
|
356
|
+
return ![".git", "node_modules", ".cache", ".npm", ".pnpm-store"].includes(name)
|
|
357
|
+
&& name !== ".env" && !name.startsWith(".env.");
|
|
358
|
+
}
|
|
346
359
|
function treeHash(root) {
|
|
347
360
|
const hash = crypto.createHash("sha256");
|
|
348
361
|
const visit = (dir, relative) => {
|
|
@@ -370,7 +383,7 @@ function readManifest(snapshot) {
|
|
|
370
383
|
if (!fs.existsSync(file))
|
|
371
384
|
throw new AppError("snapshot_missing", "Snapshot manifest is missing", 1, { snapshot });
|
|
372
385
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
373
|
-
if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose
|
|
386
|
+
if (value.schema_id !== "dd-flow/eval-run-snapshot@5" || !value.run_id || !value.project_root || !value.dd_flow_home || typeof value.purpose !== "string" || !["stage_entry", "candidate", "incomplete"].includes(value.purpose) || (value.purpose === "stage_entry" && !value.stage_entry) || (value.purpose !== "stage_entry" && value.stage_entry !== null) || !value.runtime_sha256 || !value.project_sha256 || !value.project_git || !value.workspace)
|
|
374
387
|
throw new AppError("snapshot_invalid", "Snapshot manifest is invalid", 1, { snapshot });
|
|
375
388
|
if (!value.project_git.branch || !value.project_git.head || !value.project_git.bundle_sha256)
|
|
376
389
|
throw new AppError("snapshot_invalid", "Snapshot project Git metadata is incomplete", 1, { snapshot });
|
package/dist/services/runs.js
CHANGED
|
@@ -17,6 +17,7 @@ import { bindCurrentEngineToRun } from "./engines.js";
|
|
|
17
17
|
import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
|
|
18
18
|
import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
|
|
19
19
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
20
|
+
import { flowCommand } from "./stage-pause.js";
|
|
20
21
|
const runSchemaId = "dd-flow/flow-run@3";
|
|
21
22
|
const runtimeSchemaId = "dd-flow/flow-run@3";
|
|
22
23
|
const runIdType = "RUN";
|
|
@@ -252,7 +253,7 @@ function runContinuation(context, run, index) {
|
|
|
252
253
|
const next = completed ? vnextStages.find((stage) => stage.id === completed.stage)?.next.find((candidate) => candidate !== "plan-review" || index.settings?.plan_review?.mode !== "off") : "specify";
|
|
253
254
|
if (!next)
|
|
254
255
|
return { kind: "terminal", stage: null, attempt: null, work_id: null, reason: "flow_complete", command: null };
|
|
255
|
-
const command =
|
|
256
|
+
const command = `${flowCommand(context)} stage start ${run.id} --stage ${next} --project-root ${JSON.stringify(run.project_root)} --json`;
|
|
256
257
|
return { kind: "start_stage", stage: next, attempt: null, work_id: null, reason: "legal_successor", command };
|
|
257
258
|
}
|
|
258
259
|
return { kind: "wait", stage: index.current_stage ?? null, attempt: null, work_id: null, reason: "no_vnext_continuation", command: null };
|
|
@@ -789,6 +790,9 @@ export function prepareVnextMergeSourceRepairAttempt(context, input) {
|
|
|
789
790
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
790
791
|
const run = resolveRun(context, project.id, input.runId);
|
|
791
792
|
const index = authoritativeIndex(run);
|
|
793
|
+
const priorRepair = index.variables?.["merge.source_repair"];
|
|
794
|
+
if (priorRepair?.merge_request_id === input.mergeRequestId && priorRepair.cycle)
|
|
795
|
+
return { run_id: run.id, cycle: priorRepair.cycle };
|
|
792
796
|
const code = index.stage_runs.find((item) => item.stage === "code");
|
|
793
797
|
const review = index.stage_runs.find((item) => item.stage === "code-review");
|
|
794
798
|
const merge = index.stage_runs.find((item) => item.stage === "merge");
|
|
@@ -1419,28 +1423,43 @@ function resolveWorkspaceRoot(workspaceRoot) {
|
|
|
1419
1423
|
}
|
|
1420
1424
|
/** Move one completed stage attempt and update only known durable path columns.
|
|
1421
1425
|
* Receipt bytes remain immutable; readers resolve their new location through the DB. */
|
|
1422
|
-
function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
|
|
1426
|
+
export function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDir) {
|
|
1423
1427
|
const currentStageDir = path.join(runRoot, stageDir);
|
|
1424
1428
|
if (!fs.existsSync(currentStageDir) || !fs.statSync(currentStageDir).isDirectory()) {
|
|
1425
1429
|
return;
|
|
1426
1430
|
}
|
|
1427
|
-
const
|
|
1428
|
-
|
|
1431
|
+
const journal = path.join(runRoot, `.archive-${stageDir}.json`);
|
|
1432
|
+
const pending = fs.existsSync(journal) ? JSON.parse(fs.readFileSync(journal, "utf8")) : null;
|
|
1433
|
+
const entries = pending?.entries ?? fs.readdirSync(currentStageDir).filter((entry) => !/^try-\d{3}$/.test(entry));
|
|
1434
|
+
if (entries.length === 0 && !pending) {
|
|
1429
1435
|
return;
|
|
1430
1436
|
}
|
|
1431
1437
|
let attempt = 1;
|
|
1432
1438
|
while (fs.existsSync(path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`))) {
|
|
1433
1439
|
attempt += 1;
|
|
1434
1440
|
}
|
|
1435
|
-
const archiveDir = path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
|
|
1441
|
+
const archiveDir = pending?.archiveDir ?? path.join(currentStageDir, `try-${String(attempt).padStart(3, "0")}`);
|
|
1442
|
+
if (!pending) {
|
|
1443
|
+
const stored = context.db.get("SELECT index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
1444
|
+
const prior = stored ? JSON.parse(stored.index_json) : null;
|
|
1445
|
+
writeJsonFile(journal, { archiveDir, entries, stage_run: prior?.stage_runs.find((stage) => stage.dir === stageDir) ?? null });
|
|
1446
|
+
}
|
|
1436
1447
|
ensureDir(archiveDir);
|
|
1437
1448
|
for (const entry of entries) {
|
|
1438
|
-
|
|
1449
|
+
const source = path.join(currentStageDir, entry);
|
|
1450
|
+
const target = path.join(archiveDir, entry);
|
|
1451
|
+
if (fs.existsSync(source)) {
|
|
1452
|
+
if (fs.existsSync(target))
|
|
1453
|
+
throw new AppError("archive_conflict", "Both live and archived artifact exist; preserve both for recovery", 2, { source, target });
|
|
1454
|
+
fs.renameSync(source, target);
|
|
1455
|
+
}
|
|
1456
|
+
else if (!fs.existsSync(target))
|
|
1457
|
+
throw new AppError("archive_artifact_missing", "Artifact is absent from both archive locations", 2, { source, target });
|
|
1439
1458
|
}
|
|
1440
1459
|
const oldPrefix = `${currentStageDir}${path.sep}`;
|
|
1441
1460
|
const newPrefix = `${archiveDir}${path.sep}`;
|
|
1442
1461
|
const rewrite = (table, column, where, values) => {
|
|
1443
|
-
context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, ...values]);
|
|
1462
|
+
context.db.run(`UPDATE ${table} SET ${column} = ? || substr(${column}, ?) WHERE ${column} LIKE ? AND substr(${column}, ?, 4) != 'try-' AND ${where}`, [newPrefix, oldPrefix.length + 1, `${oldPrefix}%`, oldPrefix.length + 1, ...values]);
|
|
1444
1463
|
};
|
|
1445
1464
|
const workScope = "work_id IN (SELECT work_id FROM works WHERE project_id = ? AND run_id = ?)";
|
|
1446
1465
|
rewrite("work_sessions", "prompt_path", workScope, [projectId, runId]);
|
|
@@ -1448,6 +1467,9 @@ function archiveExistingStageAttempt(context, projectId, runId, runRoot, stageDi
|
|
|
1448
1467
|
const receiptScope = "project_id = ? AND run_id = ?";
|
|
1449
1468
|
for (const column of ["stdout_path", "stderr_path", "receipt_path"])
|
|
1450
1469
|
rewrite("check_receipts", column, receiptScope, [projectId, runId]);
|
|
1470
|
+
// Retain the original stage facts with its immutable artifacts; current
|
|
1471
|
+
// stage_runs stays a projection of the current attempt, not a second ledger.
|
|
1472
|
+
fs.renameSync(journal, path.join(archiveDir, "archive.json"));
|
|
1451
1473
|
}
|
|
1452
1474
|
function writeJsonFile(file, value) {
|
|
1453
1475
|
const tmpFile = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
@@ -173,7 +173,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
173
173
|
const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
174
174
|
const finalChecks = finalCodeCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }));
|
|
175
175
|
if (input.retryCheckId) {
|
|
176
|
-
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks });
|
|
176
|
+
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: finalChecks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
|
|
177
177
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
|
|
178
178
|
}
|
|
179
179
|
const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
|
|
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
7
|
-
import { aggregateCheckDeclarations, checkReceipts, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
|
|
7
|
+
import { aggregateCheckDeclarations, checkReceipts, effectiveCheckDeclarations, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, validateFailedCheckRetry, workspaceChangedPaths, workspaceFingerprint } from "./code-checks.js";
|
|
8
8
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
9
|
import { appendFlowRunTimelineEvent, advanceFlowRun, attachFlowRunStage, completeFlowRunStage, completeFlowRun, getFlowRunVariables, gitFacts } from "./runs.js";
|
|
10
10
|
import { validateSchema } from "./schema-validation.js";
|
|
@@ -158,7 +158,7 @@ export async function finishVnextCode(context, input) {
|
|
|
158
158
|
}
|
|
159
159
|
const checks = finalCodeCheckDeclarations(run.workspace_root, works.flatMap((work) => packet(work)?.checks ?? []));
|
|
160
160
|
if (input.retryCheckId) {
|
|
161
|
-
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks });
|
|
161
|
+
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
|
|
162
162
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
|
|
163
163
|
}
|
|
164
164
|
const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
|
|
@@ -289,6 +289,11 @@ export function addVnextCodeRepair(context, input) {
|
|
|
289
289
|
const home = requireHome(run);
|
|
290
290
|
if (!input.objective.trim())
|
|
291
291
|
throw new AppError("validation", "Repair objective must not be empty", 2);
|
|
292
|
+
if (input.reviewCycle) {
|
|
293
|
+
const previous = codeWorks(context, project.id, run.id).find((work) => packet(work)?.review_cycle === input.reviewCycle);
|
|
294
|
+
if (previous)
|
|
295
|
+
return { ok: true, run_id: run.id, repair_work_id: previous.work_id, start_command: workStartCommand(context, previous), reused: true };
|
|
296
|
+
}
|
|
292
297
|
if (!input.checkReceiptId && !input.reviewFindings?.length && !input.semanticUnresolved?.length) {
|
|
293
298
|
throw new AppError("validation", "Repair needs failed check evidence, a CODE-REVIEW finding, or an unresolved CODE verification", 2);
|
|
294
299
|
}
|
|
@@ -312,10 +317,10 @@ export function addVnextCodeRepair(context, input) {
|
|
|
312
317
|
// repair that fixes one failed aggregate check can make a second, already
|
|
313
318
|
// declared aggregate failure impossible to repair. The immutable source
|
|
314
319
|
// of repair eligibility is the original CODE graph.
|
|
315
|
-
const declaredChecks =
|
|
320
|
+
const declaredChecks = effectiveCheckDeclarations(run.workspace_root, codeWorks(context, project.id, run.id)
|
|
316
321
|
.map((work) => packet(work))
|
|
317
322
|
.filter((value) => !value.repair)
|
|
318
|
-
.flatMap((value) => value.checks));
|
|
323
|
+
.flatMap((value) => value.checks), ["work", "code", "readiness", "merge"]);
|
|
319
324
|
const failedCheck = receipt ? declaredChecks.find((check) => check.id === receipt.declaration_id) : undefined;
|
|
320
325
|
if (receipt && !failedCheck) {
|
|
321
326
|
throw new AppError("repair_check_declaration_missing", "Repair receipt is not backed by an accepted CODE check declaration", 2, {
|
|
@@ -3,8 +3,10 @@ import path from "node:path";
|
|
|
3
3
|
import { AppError } from "../shared/errors.js";
|
|
4
4
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
5
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
6
|
-
import { getFlowRunVariables } from "./runs.js";
|
|
7
|
-
import { listWorks } from "./work-registry.js";
|
|
6
|
+
import { getFlowRunVariables, appendFlowRunTimelineEvent } from "./runs.js";
|
|
7
|
+
import { listWorks, failWork, cancelWork } from "./work-registry.js";
|
|
8
|
+
import { storageSessionId } from "./session-identity.js";
|
|
9
|
+
import { reconcileUnfinishedChecks } from "./code-checks.js";
|
|
8
10
|
import { vnextStageDirectory } from "../domain/stage-catalog.js";
|
|
9
11
|
export const subagentCapacityKey = "runtime.subagent.available_slots";
|
|
10
12
|
export function fanoutState(input) {
|
|
@@ -70,3 +72,66 @@ export function getVnextFanoutStatus(context, input) {
|
|
|
70
72
|
}
|
|
71
73
|
};
|
|
72
74
|
}
|
|
75
|
+
/** Explicit controller mutation, separate from read-only fanout status.
|
|
76
|
+
* A native child may be mapped only by its registered identity. Reused native
|
|
77
|
+
* Sessions need a provider execution identity: without one we report ambiguity
|
|
78
|
+
* instead of applying a late event to the newest Work attempt. */
|
|
79
|
+
export async function reconcileVnextFanout(context, input) {
|
|
80
|
+
const status = getVnextFanoutStatus(context, input);
|
|
81
|
+
if (!status.orchestration)
|
|
82
|
+
return { ok: true, issues: [], reconciled: [] };
|
|
83
|
+
const value = input.observations;
|
|
84
|
+
if (!value || typeof value.harness_id !== "string" || !value.harness_id || typeof value.parent_session_id !== "string" || !Array.isArray(value.children)
|
|
85
|
+
|| value.children.some(child => !child || typeof child.session_id !== "string" || !["running", "unknown", "completed", "failed", "cancelled"].includes(String(child.status))))
|
|
86
|
+
throw new AppError("usage", "Expected native observations: harness_id, parent_session_id and children[{session_id,status}]", 2);
|
|
87
|
+
if (new Set(value.children.map(child => child.session_id)).size !== value.children.length)
|
|
88
|
+
throw new AppError("usage", "Native observations must contain each child Session exactly once", 2);
|
|
89
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
90
|
+
const parent = storageSessionId({ harness_id: value.harness_id, session_id: value.parent_session_id });
|
|
91
|
+
const issues = [], reconciled = [];
|
|
92
|
+
for (const child of value.children) {
|
|
93
|
+
if (child.status === "running" || child.status === "unknown")
|
|
94
|
+
continue;
|
|
95
|
+
const sessionId = storageSessionId({ harness_id: value.harness_id, session_id: String(child.session_id) });
|
|
96
|
+
const links = context.db.all(`SELECT ws.id, ws.work_id, ws.status, w.status AS work_status, w.parent_work_id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN sessions s ON s.project_id = w.project_id AND s.session_id = ws.session_id WHERE w.project_id = ? AND w.run_id = ? AND ws.session_id = ? AND s.parent_session_id = ? ORDER BY ws.created_at`, [project.id, input.runId, sessionId, parent]);
|
|
97
|
+
const active = links.filter(link => link.status === "running" && link.work_status === "running" && link.parent_work_id === status.orchestration.parent_work_id);
|
|
98
|
+
if (!active.length) {
|
|
99
|
+
if (!links.length)
|
|
100
|
+
issues.push({ code: "child_launch_association_missing", session_id: child.session_id, outcome: child.status });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (links.length !== 1 || active.length !== 1) {
|
|
104
|
+
issues.push({ code: "child_execution_ambiguous", session_id: child.session_id, work_ids: active.map(link => link.work_id) });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const link = active[0];
|
|
108
|
+
const pendingCheck = () => context.db.get("SELECT 1 FROM check_receipts WHERE project_id = ? AND run_id = ? AND work_id = ? AND status = 'running' LIMIT 1", [project.id, input.runId, link.work_id]);
|
|
109
|
+
if (pendingCheck()) {
|
|
110
|
+
const run = context.db.get("SELECT workspace_root, run_root FROM runs WHERE project_id = ? AND id = ?", [project.id, input.runId]);
|
|
111
|
+
try {
|
|
112
|
+
reconcileUnfinishedChecks(context, { projectId: project.id, runId: input.runId, workspaceRoot: run.workspace_root, runHome: run.run_root, workId: link.work_id });
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (!(error instanceof AppError) || error.code !== "check_in_progress")
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
if (pendingCheck()) {
|
|
119
|
+
issues.push({ code: "child_check_in_progress", work_id: link.work_id });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (context.db.get("SELECT 1 FROM works WHERE parent_work_id = ? AND status IN ('created','running','paused') LIMIT 1", [link.work_id])) {
|
|
124
|
+
issues.push({ code: "child_descendants_unsettled", work_id: link.work_id });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (child.status === "completed") {
|
|
128
|
+
issues.push({ code: "execution_ended_without_work_result", work_id: link.work_id, session_id: child.session_id });
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const reason = JSON.stringify({ source: "native_child_observation", harness_id: value.harness_id, session_id: child.session_id, work_session_id: link.id, outcome: child.status });
|
|
132
|
+
const result = await (child.status === "failed" ? failWork : cancelWork)(context, link.work_id, reason);
|
|
133
|
+
reconciled.push({ work_id: link.work_id, work_session_id: link.id, outcome: child.status, result });
|
|
134
|
+
}
|
|
135
|
+
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "native_child_reconciliation", stage: input.stage, observations: value, issues, reconciled });
|
|
136
|
+
return { ok: true, issues, reconciled };
|
|
137
|
+
}
|
|
@@ -227,7 +227,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
227
227
|
}
|
|
228
228
|
const checks = effectiveMergeChecks(run, request);
|
|
229
229
|
if (input.retryCheckId) {
|
|
230
|
-
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks });
|
|
230
|
+
validateFailedCheckRetry(context, { projectId: project.id, runId: run.id, receiptId: input.retryCheckId, declarations: checks, artifactRoot: path.join(requireHome(run), stageDir), reason: input.retryReason });
|
|
231
231
|
appendFlowRunTimelineEvent(context, project.id, run.id, { type: "check_retry_requested", check_receipt_id: input.retryCheckId, stage, reason: input.retryReason ?? "operator retry" });
|
|
232
232
|
}
|
|
233
233
|
const unchanged = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: request.target_workspace, declarations: checks, ...(input.retryCheckId ? { retryReceiptId: input.retryCheckId } : {}) });
|
|
@@ -314,6 +314,11 @@ export async function repairVnextMerge(context, input) {
|
|
|
314
314
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
315
315
|
const project = requireProjectByRoot(context, projectRoot);
|
|
316
316
|
const request = requireRequest(context, input.requestId);
|
|
317
|
+
if (request.project_id === project.id && request.status === "superseded" && errorCode(request) === "merge_source_repair") {
|
|
318
|
+
const saved = JSON.parse(request.last_error_json);
|
|
319
|
+
releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
|
|
320
|
+
return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: { repair_work_id: saved.repair_work_id }, next: { kind: "start_stage", stage: "code", command: `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } };
|
|
321
|
+
}
|
|
317
322
|
if (request.project_id !== project.id || request.status !== "action_required" || errorCode(request) !== "merge_gate_failed") {
|
|
318
323
|
throw new AppError("invalid_merge_repair_state", "MERGE source repair requires an action-required failed integration gate", 2, { merge_request_id: input.requestId, status: request.status, code: errorCode(request) });
|
|
319
324
|
}
|
|
@@ -326,16 +331,18 @@ export async function repairVnextMerge(context, input) {
|
|
|
326
331
|
throw new AppError("runtime_missing", "MERGE failure has no durable failed receipt", 1, { merge_request_id: request.merge_request_id });
|
|
327
332
|
// Settle the child while its canonical result path still exists; the next
|
|
328
333
|
// step archives the entire MERGE attempt as immutable evidence.
|
|
329
|
-
|
|
334
|
+
const executor = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
|
|
335
|
+
if (executor?.status !== "failed")
|
|
336
|
+
await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
|
|
330
337
|
const prepared = prepareVnextMergeSourceRepairAttempt(context, { projectRoot, runId: request.run_id, mergeRequestId: request.merge_request_id });
|
|
331
|
-
const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
|
|
338
|
+
const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, reviewCycle: `MRR-${request.merge_request_id}`, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
|
|
332
339
|
const now = context.now();
|
|
333
340
|
context.db.run("UPDATE merge_requests SET status = 'superseded', checkpoint = 'source_repair_required', last_error_json = ?, completed_at = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_source_repair", replacement_pending: true, failed_receipt_ids: failed, repair_work_id: repair.repair_work_id }), now, now, request.merge_request_id]);
|
|
334
341
|
releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
|
|
335
342
|
appendFlowRunTimelineEvent(context, project.id, request.run_id, { type: "merge_source_repair_created", merge_request_id: request.merge_request_id, repair_work_id: repair.repair_work_id, cycle: prepared.cycle, failed_receipt_ids: failed });
|
|
336
343
|
return { ok: true, run_id: request.run_id, merge_request_id: request.merge_request_id, status: "superseded", source_repair: repair, next: { kind: "start_stage", stage: "code", command: `${flowCommand(context)} stage start ${request.run_id} --stage code --project-root ${JSON.stringify(projectRoot)} --json --progress-jsonl` } };
|
|
337
344
|
}
|
|
338
|
-
function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`; const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails,
|
|
345
|
+
function mergePrompt(context, input) { const pause = `${flowCommand(context)} stage pause ${input.run.id} --stage merge --work ${input.request.executor_work_id} --project-root ${JSON.stringify(input.projectRoot)} --question-stdin --json`; const repair = `${flowCommand(context)} merge repair ${input.request.merge_request_id} --project-root ${JSON.stringify(input.projectRoot)} --json`; return ["<stage_identity>", `- RUN: ${input.run.id}`, `- MERGE request: ${input.request.merge_request_id}`, `- Work: ${input.request.executor_work_id}`, "- stage: merge", "</stage_identity>", "", "<trusted_runtime_context>", `- integration workspace: ${input.request.target_workspace}`, `- source workspace: ${input.request.source_workspace}`, `- frozen source commit: ${input.request.source_commit}`, `- target branch: ${input.request.target_branch}`, `- execution target baseline: ${input.request.execution_target_head}`, `- queue route: ${input.request.execution_route}`, `- delivery: ${JSON.stringify(executionSettings(input.run).merge_delivery)}`, `- cleanup: ${JSON.stringify(executionSettings(input.run).merge_cleanup)}`, "These facts and the acquired project integration lane were established by dd-flow. Do not repeat discovery and do not run git merge/rebase/squash yourself.", "</trusted_runtime_context>", "", "<effective_merge_gate>", ...effectiveMergeChecks(input.run, input.request).map((check) => `- ${check.canonical_ref ?? check.id}: ${check.command} — ${check.purpose}`), "</effective_merge_gate>", "", "<execution_contract>", `1. Run this exact standalone command first: ${applyCommand(context, input.request, input.projectRoot)}`, "2. If it reports conflicts, resolve only the actual unmerged paths in the integration workspace. Do not repeat merge apply and do not edit product code, tests, documentation or configuration merely to make a gate pass.", `3. Write the compact semantic result to ${input.resultPath}:`, "```json", JSON.stringify({ schema_id: "dd-flow/merge-result@1", outcome: "completed", summary: "What was integrated.", conflict_resolution: "How material conflicts were resolved, or empty when none.", verification_summary: "Why the integrated result is ready for deterministic checks.", residual_risks: [] }, null, 2), "```", `4. Finish with this exact standalone command and wait for all progress: ${finishCommand(context, input.run.id, input.request, input.projectRoot)}`, `If a gate fails, read its receipt and logs and determine the cause. For a product defect use ${repair}; it restores the target baseline and opens CODE → independent CODE-REVIEW → replacement MRG. For an environment failure restore the declared environment in this MERGE and use the returned finish command with --retry-check <receipt-id> --reason "<what was restored>". The CLI reruns the real gate and retains the old receipt. Never edit product code in the integration target to make a gate pass.`, "If a material conflict has no reasonable answer in accepted evidence, pause this same Work with the exact heredoc below, ask the returned user_message, then use the exact resume command returned by CLI:", "```sh", stagePauseCommandTemplate(pause), "```", "</execution_contract>", ""].join("\n"); }
|
|
339
346
|
function mergeReport(context, run, request, semantic, receipts) { const now = context.now(); const cleanup = cleanupReceiptPath(run); return { schema_id: "dd-flow/stage-report@2", run_id: run.id, stage, generated_at: now, verdict: "done", summary: semantic.summary, semantic: { result: semantic.summary, acceptance: ["source_commit_frozen", "integration_commit_created", "merge_gate_passed", "delivery_confirmed"], changed_files: [], checks: receipts.map((item) => item.command), evidence: [applyReceiptPath(context, request), ...receipts.map((item) => item.receipt_path), ...(fs.existsSync(cleanup) ? [cleanup] : [])], next_action: "merge_completed", merge: { merge_request_id: request.merge_request_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source_commit: request.source_commit, execution_target_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit, route: request.execution_route, delivery: executionSettings(run).merge_delivery, cleanup: executionSettings(run).merge_cleanup, verification_summary: semantic.verification_summary, residual_risks: semantic.residual_risks } }, mechanical: { started_at: request.lock_acquired_at, finished_at: now, git: gitFacts(request.target_workspace), queue: queueStatus(context, request) }, artifacts: { json: "stage-report.json", markdown: "stage-report.md", html: "stage-report.html" }, validation: { status: "passed" } }; }
|
|
340
347
|
function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
|
|
341
348
|
function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
|
|
@@ -400,7 +407,12 @@ function releaseMergeLane(context, projectRoot, request, reason) {
|
|
|
400
407
|
try {
|
|
401
408
|
releaseLaneLock(context, { projectRoot, lane: "merge", workerId: request.executor_work_id, workspacePath: request.target_workspace, reason });
|
|
402
409
|
}
|
|
403
|
-
catch {
|
|
410
|
+
catch (error) {
|
|
411
|
+
// An absent/replaced lease is already no longer ours. Storage and other
|
|
412
|
+
// failures must remain visible so a repeated operation can release it.
|
|
413
|
+
if (!(error instanceof AppError) || !["lane_lock_not_active", "lane_lock_owned_by_other_worker"].includes(error.code))
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
404
416
|
}
|
|
405
417
|
function queueAhead(context, request) { return context.db.get("SELECT COUNT(*) AS count FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?))", [request.project_id, request.created_at, request.created_at, request.merge_request_id])?.count ?? 0; }
|
|
406
418
|
function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
|