@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.14
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 +22 -0
- package/dist/build-info.json +5 -5
- package/dist/cli/help.js +2 -1
- package/dist/cli/run-cli.js +6 -3
- package/dist/schemas/code-work-batch.schema.json +1 -1
- package/dist/schemas/code-work-result.schema.json +3 -3
- package/dist/services/code-checks.js +71 -33
- package/dist/services/managed-processes.js +30 -8
- package/dist/services/merge-server.js +1 -1
- package/dist/services/runs.js +61 -1
- package/dist/services/vnext-code-review.js +23 -11
- package/dist/services/vnext-code.js +32 -45
- package/dist/services/vnext-merge.js +78 -14
- package/dist/services/vnext-plan.js +1 -1
- package/dist/services/work-registry.js +36 -12
- package/dist/services/workspace-bootstrap.js +50 -0
- package/dist/storage/database.js +8 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @deksden-com/dd-flow-cli
|
|
2
2
|
|
|
3
|
+
## 0.9.0-beta.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Audit runtime recovery: distinguish process owners from children, retain live check resources, await failed check startup cleanup, share managed workspace bootstrap between CODE/MERGE, correct assigned repair examples, and reject publication without canonical build provenance.
|
|
8
|
+
|
|
9
|
+
## 0.9.0-beta.13
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Make long-running CODE checks suspend-safe and prevent an expired lease from
|
|
14
|
+
stealing a live process. Publish `dd-flow/code-work-result@3`, which unifies
|
|
15
|
+
repair resolutions with evidence references assigned by the Work packet.
|
|
16
|
+
|
|
17
|
+
## 0.9.0-beta.12
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- Return a failed integration gate to an explicit source CODE and independent
|
|
22
|
+
CODE-REVIEW repair cycle, instead of allowing product repairs in the
|
|
23
|
+
integration workspace.
|
|
24
|
+
|
|
3
25
|
## 0.9.0-beta.11
|
|
4
26
|
|
|
5
27
|
### 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.14",
|
|
4
|
+
"cli_commit": "29b30887789f9ee686d5903a4ddcea40a24dc8ce",
|
|
5
|
+
"built_at": "2026-09-05T08:57:36.734Z",
|
|
6
6
|
"built_with_canon": {
|
|
7
|
-
"version": "4.0.
|
|
8
|
-
"commit": "
|
|
7
|
+
"version": "4.0.4",
|
|
8
|
+
"commit": "c8cae271f844fd3b3c7492f164e13a60eaba4839",
|
|
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
|
@@ -369,11 +369,12 @@ Usage:
|
|
|
369
369
|
dd-flow merge request status <MRG-ID> --project-root <root> --json
|
|
370
370
|
dd-flow merge request route <MRG-ID> --mode same_session|server --reason <text> --project-root <root> --json
|
|
371
371
|
dd-flow merge apply <MRG-ID> --work <WRK-ID> --project-root <root> --json --progress-jsonl
|
|
372
|
+
dd-flow merge repair <MRG-ID> --project-root <root> --json
|
|
372
373
|
dd-flow merge serve --agent-profile <id> [--once] [--poll-seconds <n>] [--max-parallel-projects <n>] --progress-jsonl
|
|
373
374
|
dd-flow merge server status --json
|
|
374
375
|
dd-flow merge server stop <MSV-ID> --json
|
|
375
376
|
|
|
376
|
-
CODE or CODE-REVIEW creates one MRG request and child MERGE Work. stage start waits in the shared project FIFO, reports progress, locks the execution target baseline and returns the exact apply/finish commands. merge apply is the only supported initial Git mutation. merge serve is a deterministic dispatcher: it launches a fresh Session through the configured harness adapter but does not perform agent work itself. It preserves FIFO inside each project and may serve different projects concurrently up to the explicit bound.`
|
|
377
|
+
CODE or CODE-REVIEW creates one MRG request and child MERGE Work. stage start waits in the shared project FIFO, reports progress, locks the execution target baseline and returns the exact apply/finish commands. merge apply is the only supported initial Git mutation. If its integration gate fails, merge repair restores the target baseline and creates a source CODE → independent CODE-REVIEW → replacement-MRG cycle; it never patches product code in the integration workspace. merge serve is a deterministic dispatcher: it launches a fresh Session through the configured harness adapter but does not perform agent work itself. It preserves FIFO inside each project and may serve different projects concurrently up to the explicit bound.`
|
|
377
378
|
],
|
|
378
379
|
[
|
|
379
380
|
"cleanup",
|
package/dist/cli/run-cli.js
CHANGED
|
@@ -39,7 +39,7 @@ import { finishVnextPlan, isVnextPlanRun, startVnextPlan } from "../services/vne
|
|
|
39
39
|
import { dispatchVnextPlanReview, finishVnextPlanReview, isVnextPlanReviewRun, recordVnextPlanReviewCapacity, startVnextPlanReview } from "../services/vnext-plan-review.js";
|
|
40
40
|
import { addVnextCodeRepair, finishVnextCode, startVnextCode } from "../services/vnext-code.js";
|
|
41
41
|
import { addVnextCodeReviewRepair, finishVnextCodeReview, isVnextCodeReviewRun, startVnextCodeReview } from "../services/vnext-code-review.js";
|
|
42
|
-
import { applyVnextMerge, finishVnextMerge, getVnextMergeRequest, isVnextMergeRun, routeVnextMergeRequest, startVnextMerge } from "../services/vnext-merge.js";
|
|
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
44
|
import { getVnextFanoutStatus } from "../services/vnext-fanout.js";
|
|
45
45
|
import { pauseStageForUser, resumeStageAfterUser } from "../services/stage-pause.js";
|
|
@@ -1247,6 +1247,8 @@ async function dispatchMerge(context, command, parsed, progress) {
|
|
|
1247
1247
|
}
|
|
1248
1248
|
if (command === "apply")
|
|
1249
1249
|
return applyVnextMerge(context, { projectRoot: requiredOption(parsed, "project-root"), requestId: requiredPosition(parsed, 0, "merge-request-id"), workId: requiredOption(parsed, "work"), ...(progress ? { progress } : {}) });
|
|
1250
|
+
if (command === "repair")
|
|
1251
|
+
return await repairVnextMerge(context, { projectRoot: requiredOption(parsed, "project-root"), requestId: requiredPosition(parsed, 0, "merge-request-id") });
|
|
1250
1252
|
if (command === "request") {
|
|
1251
1253
|
const action = requiredPosition(parsed, 0, "request action");
|
|
1252
1254
|
const requestId = requiredPosition(parsed, 1, "merge-request-id");
|
|
@@ -1589,6 +1591,7 @@ async function dispatchRuntime(context, command, parsed) {
|
|
|
1589
1591
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
1590
1592
|
const process = registerManagedProcess(context, {
|
|
1591
1593
|
kind: requiredOption(parsed, "kind"), ownerId: requiredOption(parsed, "owner"),
|
|
1594
|
+
ownerPid: optionalPositiveNumber(parsed, "owner-pid", true),
|
|
1592
1595
|
projectId: optionalOption(parsed, "project-id") ?? null, runId: optionalOption(parsed, "run") ?? null,
|
|
1593
1596
|
workId: optionalOption(parsed, "work") ?? null, checkId: optionalOption(parsed, "check") ?? null,
|
|
1594
1597
|
operationId: optionalOption(parsed, "operation") ?? null, stdoutPath: optionalOption(parsed, "stdout") ?? null,
|
|
@@ -1599,7 +1602,7 @@ async function dispatchRuntime(context, command, parsed) {
|
|
|
1599
1602
|
if (action === "confirm") {
|
|
1600
1603
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
1601
1604
|
const processGroupId = optionalPositiveNumber(parsed, "process-group-id", true);
|
|
1602
|
-
return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
|
|
1605
|
+
return { ok: true, process: confirmManagedProcess(context, { id: requiredOption(parsed, "id"), leaseToken: requiredOption(parsed, "lease-token"), ownerPid: optionalPositiveNumber(parsed, "owner-pid", true), pid: optionalPositiveNumber(parsed, "pid", true) ?? 0, ...(processGroupId ? { processGroupId } : {}), ...(leaseMs ? { leaseMs } : {}) }) };
|
|
1603
1606
|
}
|
|
1604
1607
|
if (action === "heartbeat") {
|
|
1605
1608
|
const leaseMs = optionalPositiveNumber(parsed, "lease-ms", true);
|
|
@@ -2324,7 +2327,7 @@ function projectRootForMutation(context, args, result, resolvedScope) {
|
|
|
2324
2327
|
if (family === "merge-queue") {
|
|
2325
2328
|
return projectRootForMergeQueueMutation(context, command, parsed);
|
|
2326
2329
|
}
|
|
2327
|
-
if (family === "merge" && ["one-shot", "apply", "request"].includes(command ?? "")) {
|
|
2330
|
+
if (family === "merge" && ["one-shot", "apply", "repair", "request"].includes(command ?? "")) {
|
|
2328
2331
|
return requiredOption(parsed, "project-root");
|
|
2329
2332
|
}
|
|
2330
2333
|
if (family === "merge-worker" && ["start", "stop"].includes(command ?? "")) {
|
|
@@ -20,6 +20,6 @@
|
|
|
20
20
|
"documentUpdate": {"type": "object", "additionalProperties": false, "required": ["path", "action", "owner", "reason", "baseline_sha256"], "properties": {"path": {"type": "string", "minLength": 1}, "action": {"enum": ["create", "update"]}, "owner": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}, "baseline_sha256": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}}},
|
|
21
21
|
"reviewFinding": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "priority", "problem", "impact", "required_outcome", "evidence_refs", "obligation_refs", "decision_reason", "check_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "priority": {"enum": ["p0", "p1", "p2", "p3"]}, "problem": {"type": "string", "minLength": 1}, "impact": {"type": "string", "minLength": 1}, "required_outcome": {"type": "string", "minLength": 1}, "evidence_refs": {"$ref": "#/$defs/strings"}, "obligation_refs": {"$ref": "#/$defs/strings"}, "decision_reason": {"type": "string", "minLength": 1}, "check_refs": {"$ref": "#/$defs/strings"}}},
|
|
22
22
|
"repair": {"type": "object", "additionalProperties": false, "required": ["origin_work_ids"], "anyOf": [{"required": ["check_receipt_id", "failure_receipt_path"]}, {"required": ["review_findings"]}, {"required": ["semantic_unresolved", "verification_path"]}], "properties": {"origin_work_ids": {"$ref": "#/$defs/strings"}, "check_receipt_id": {"type": "string", "minLength": 1}, "failure_receipt_path": {"type": "string", "minLength": 1}, "review_findings": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/reviewFinding"}}, "semantic_unresolved": {"$ref": "#/$defs/strings"}, "verification_path": {"type": "string", "minLength": 1}}},
|
|
23
|
-
"work": {"type": "object", "additionalProperties": false, "required": ["schema_id", "key", "launch_policy", "source", "task", "semantic_spine", "requirements", "acceptance", "document_updates", "required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions", "depends_on", "result_schema"], "properties": {"schema_id": {"const": "dd-flow/code-work-packet@5"}, "key": {"type": "string", "minLength": 1}, "launch_policy": {"const": "fresh_agent_required"}, "source": {"$ref": "#/$defs/source"}, "repair": {"$ref": "#/$defs/repair"}, "task": {"type": "string", "minLength": 1}, "semantic_spine": {"$ref": "#/$defs/spine"}, "requirements": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/obligation"}}, "acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}}, "document_updates": {"type": "array", "items": {"$ref": "#/$defs/documentUpdate"}}, "required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources, not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas; additional project-local reads are allowed."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Soft coordination hints only. A worker may change any necessary project file inside the RUN workspace."}, "checks": {"type": "array", "items": {"$ref": "#/$defs/check"}}, "provides_checks": {"type": "array", "items": {"$ref": "#/$defs/check"}}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Hard semantic or external stop conditions; path prediction drift is not a stop condition."}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "result_schema": {"const": "dd-flow/code-work-result@
|
|
23
|
+
"work": {"type": "object", "additionalProperties": false, "required": ["schema_id", "key", "launch_policy", "source", "task", "semantic_spine", "requirements", "acceptance", "document_updates", "required_read", "discovery_boundary", "planned_write_areas", "checks", "provides_checks", "stop_conditions", "depends_on", "result_schema"], "properties": {"schema_id": {"const": "dd-flow/code-work-packet@5"}, "key": {"type": "string", "minLength": 1}, "launch_policy": {"const": "fresh_agent_required"}, "source": {"$ref": "#/$defs/source"}, "repair": {"$ref": "#/$defs/repair"}, "task": {"type": "string", "minLength": 1}, "semantic_spine": {"$ref": "#/$defs/spine"}, "requirements": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/obligation"}}, "acceptance": {"type": "array", "items": {"$ref": "#/$defs/acceptance"}}, "document_updates": {"type": "array", "items": {"$ref": "#/$defs/documentUpdate"}}, "required_read": {"$ref": "#/$defs/strings", "description": "Mandatory starting sources, not a read allowlist."}, "discovery_boundary": {"$ref": "#/$defs/strings", "description": "Likely discovery areas; additional project-local reads are allowed."}, "planned_write_areas": {"type": "array", "items": {"type": "string", "minLength": 1}, "description": "Soft coordination hints only. A worker may change any necessary project file inside the RUN workspace."}, "checks": {"type": "array", "items": {"$ref": "#/$defs/check"}}, "provides_checks": {"type": "array", "items": {"$ref": "#/$defs/check"}}, "stop_conditions": {"$ref": "#/$defs/strings", "description": "Hard semantic or external stop conditions; path prediction drift is not a stop condition."}, "depends_on": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "result_schema": {"const": "dd-flow/code-work-result@3"}}}
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "dd-flow/code-work-result@
|
|
3
|
+
"$id": "dd-flow/code-work-result@3",
|
|
4
4
|
"type": "object",
|
|
5
5
|
"additionalProperties": false,
|
|
6
6
|
"required": ["schema_id", "summary", "changed_paths", "evidence", "deviations", "blockers"],
|
|
7
7
|
"properties": {
|
|
8
|
-
"schema_id": {"const": "dd-flow/code-work-result@
|
|
8
|
+
"schema_id": {"const": "dd-flow/code-work-result@3"},
|
|
9
9
|
"summary": {"type": "string", "minLength": 1},
|
|
10
10
|
"changed_paths": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
|
|
11
|
-
"evidence": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["
|
|
11
|
+
"evidence": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["obligation_ref", "refs"], "properties": {"obligation_ref": {"type": "string", "minLength": 1}, "refs": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}},
|
|
12
12
|
"deviations": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
|
13
13
|
"blockers": {"type": "array", "items": {"type": "string", "minLength": 1}},
|
|
14
14
|
"resolutions": {"type": "array", "uniqueItems": true, "items": {"type": "object", "additionalProperties": false, "required": ["finding_ref", "summary", "evidence_refs"], "properties": {"finding_ref": {"type": "string", "minLength": 1}, "summary": {"type": "string", "minLength": 1}, "evidence_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}}}}}
|
|
@@ -3,7 +3,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
|
-
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus,
|
|
6
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedProcessStatus, managedOwnerIsAlive, processTreeIsAlive, registerManagedProcess, reservePorts } from "./managed-processes.js";
|
|
7
7
|
const profileRelativePath = path.join(".memory-bank", "spec", "engineering", "code-check-profile.json");
|
|
8
8
|
// Receipt reservation precedes child spawn so an interrupted CLI leaves an
|
|
9
9
|
// auditable attempt. A second caller must not mistake that small handoff window
|
|
@@ -99,7 +99,7 @@ export async function runCodeChecks(context, input) {
|
|
|
99
99
|
try {
|
|
100
100
|
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
101
101
|
writeReceipt(receiptPath, receiptFor({ ...reserved, resources, result, status: "running", after: before, mutationPaths: [], artifacts: [] }));
|
|
102
|
-
const managed = registerManagedProcess(context, { kind: "check", ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
|
|
102
|
+
const managed = registerManagedProcess(context, { kind: "check", ownerPid: process.pid, ownerId: `check:${id}`, projectId: input.projectId, runId: input.runId, workId: input.workId ?? null, checkId: id, stdoutPath, stderrPath, metadata: { command: group.command } });
|
|
103
103
|
const allocation = await reservePorts(context, { ownerId: managed.owner_id, processId: managed.id, names: group.ports });
|
|
104
104
|
resources = allocation.ports;
|
|
105
105
|
input.progress?.(`check ${index + 1}/${groups.length} started: ${group.command}`);
|
|
@@ -109,13 +109,18 @@ export async function runCodeChecks(context, input) {
|
|
|
109
109
|
result = await runCheck(group.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_EVIDENCE_DIR: evidenceDir, DD_FLOW_CHECK_COMPLETION_FILE: completionPath, ...portEnvironment(resources) }, stdout, stderr, (elapsed) => input.progress?.(`check ${index + 1}/${groups.length} still running (${elapsed}s): ${group.command}`), (pid) => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
110
110
|
}
|
|
111
111
|
finally {
|
|
112
|
-
allocation.release();
|
|
113
112
|
fs.closeSync(stdout);
|
|
114
113
|
fs.closeSync(stderr);
|
|
115
|
-
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
116
114
|
}
|
|
115
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
116
|
+
if (processTreeIsAlive(current))
|
|
117
|
+
throw new AppError("check_in_progress", "The check shell exited but its process tree is still live; retain this receipt and its resources until settlement", 2, { process_id: managed.id, check_receipt_id: id });
|
|
118
|
+
allocation.release();
|
|
119
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: result.aborted || result.exitCode !== 0 || Boolean(result.error) ? "failed" : "stopped", reason: result.error });
|
|
117
120
|
}
|
|
118
121
|
catch (error) {
|
|
122
|
+
if (error instanceof AppError && error.code === "check_in_progress")
|
|
123
|
+
throw error;
|
|
119
124
|
result = { exitCode: null, error: error instanceof Error ? error.message : String(error), aborted: true };
|
|
120
125
|
}
|
|
121
126
|
const after = workspaceState(input.workspaceRoot, [input.runHome]);
|
|
@@ -155,7 +160,10 @@ export function reconcileUnfinishedChecks(context, input) {
|
|
|
155
160
|
const process = processes.filter((item) => item.check_id === pendingReceipt.id).at(-1);
|
|
156
161
|
const receipt = readReceipt(pendingReceipt.receipt_path);
|
|
157
162
|
const completion = receipt ? readCompletion(receipt.completion_path || path.join(path.dirname(pendingReceipt.receipt_path), "completion.json")) : null;
|
|
158
|
-
|
|
163
|
+
// A stale lease after host sleep is not permission to duplicate a living
|
|
164
|
+
// check. Its durable completion marker or explicit reconciliation decides
|
|
165
|
+
// the outcome before another invocation may start.
|
|
166
|
+
if (process && ["starting", "running", "stopping"].includes(process.state) && (processTreeIsAlive(process) || (process.state === "starting" && (managedOwnerIsAlive(process) !== false || Date.parse(process.lease_expires_at) > Date.parse(context.now()))))) {
|
|
159
167
|
throw new AppError("check_in_progress", "A previous check invocation is still running", 1, { check_receipt_id: pendingReceipt.id, process_id: process.id, pid: process.pid });
|
|
160
168
|
}
|
|
161
169
|
if (!process && !completion && Date.now() - Date.parse(pendingReceipt.started_at) < receiptStartingGraceMs) {
|
|
@@ -212,7 +220,6 @@ function readCompletion(file) { try {
|
|
|
212
220
|
catch {
|
|
213
221
|
return null;
|
|
214
222
|
} }
|
|
215
|
-
function processLeaseIsCurrent(process) { return Date.parse(process.lease_expires_at ?? "") >= Date.now(); }
|
|
216
223
|
export function unchangedFinalGateFailures(context, input) { const wanted = new Set(input.declarations.flatMap((check) => [check.id, checkRef(check)])); const current = workspaceFingerprint(input.workspaceRoot); const latest = new Map(); for (const receipt of checkReceipts(context, { projectId: input.projectId, runId: input.runId }).filter((item) => item.scope === "aggregate" && item.check_refs.some((ref) => wanted.has(ref))))
|
|
217
224
|
for (const ref of receipt.check_refs)
|
|
218
225
|
latest.set(ref, receipt); return [...new Set(latest.values())].filter((receipt) => receipt.status === "failed" && receipt.before_fingerprint === current); }
|
|
@@ -267,34 +274,65 @@ function collectRequiredArtifacts(root, required) { const missing = []; const it
|
|
|
267
274
|
} return { complete: missing.length === 0, missing, items }; }
|
|
268
275
|
function listFiles(root, current = root) { return fs.readdirSync(current, { withFileTypes: true }).flatMap((entry) => { if ([".git", "node_modules"].includes(entry.name))
|
|
269
276
|
return []; const absolute = path.join(current, entry.name); return entry.isDirectory() ? listFiles(root, absolute) : [path.relative(root, absolute)]; }).sort(); }
|
|
270
|
-
function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
277
|
+
export function runCheck(command, cwd, environment, stdout, stderr, heartbeat, onSpawn, renewLease) {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
const started = Date.now();
|
|
280
|
+
const child = spawn("/bin/sh", ["-lc", checkShellScript()], { cwd, env: { ...environment, DD_FLOW_CHECK_COMMAND: command }, stdio: ["ignore", stdout, stderr], detached: process.platform !== "win32" });
|
|
281
|
+
let error = null;
|
|
282
|
+
let stopping = false;
|
|
283
|
+
let progress;
|
|
284
|
+
let escalation;
|
|
285
|
+
const stop = (cause) => {
|
|
286
|
+
if (stopping)
|
|
287
|
+
return;
|
|
288
|
+
stopping = true;
|
|
289
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
290
|
+
if (progress) {
|
|
291
|
+
clearInterval(progress);
|
|
292
|
+
progress = undefined;
|
|
293
|
+
}
|
|
294
|
+
if (child.pid) {
|
|
295
|
+
terminateProcessGroup(child.pid, "SIGTERM");
|
|
296
|
+
escalation = setTimeout(() => terminateProcessGroup(child.pid, "SIGKILL"), 5_000);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
// Register handlers before checking pid or calling persistence callbacks.
|
|
300
|
+
// Failure is not completion until the spawned process has actually closed.
|
|
301
|
+
child.once("error", (value) => { error = value.message; });
|
|
302
|
+
child.once("close", (exitCode, signal) => {
|
|
303
|
+
if (progress)
|
|
304
|
+
clearInterval(progress);
|
|
305
|
+
if (escalation)
|
|
306
|
+
clearTimeout(escalation);
|
|
307
|
+
if (stopping)
|
|
308
|
+
terminateProcessGroup(child.pid, "SIGKILL");
|
|
309
|
+
resolve({ exitCode, error: error ?? (signal ? `terminated by ${signal}` : null), aborted: stopping || Boolean(signal) || Boolean(error) });
|
|
310
|
+
});
|
|
311
|
+
if (!child.pid) {
|
|
312
|
+
error = "check process did not provide a PID";
|
|
313
|
+
return;
|
|
292
314
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
315
|
+
try {
|
|
316
|
+
onSpawn(child.pid);
|
|
317
|
+
}
|
|
318
|
+
catch (cause) {
|
|
319
|
+
stop(cause);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
progress = setInterval(() => {
|
|
323
|
+
try {
|
|
324
|
+
if (!renewLease()) {
|
|
325
|
+
stop("managed process lease lost");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
heartbeat(Math.floor((Date.now() - started) / 1000));
|
|
329
|
+
}
|
|
330
|
+
catch (cause) {
|
|
331
|
+
stop(cause);
|
|
332
|
+
}
|
|
333
|
+
}, 15_000);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
298
336
|
function checkShellScript() {
|
|
299
337
|
return [
|
|
300
338
|
'completion="${DD_FLOW_CHECK_COMPLETION_FILE:-}"',
|
|
@@ -13,7 +13,7 @@ export function registerManagedProcess(context, input) {
|
|
|
13
13
|
const token = crypto.randomUUID();
|
|
14
14
|
db.run(`INSERT INTO managed_processes
|
|
15
15
|
(id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
|
|
16
|
-
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify(input.metadata
|
|
16
|
+
VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify({ ...input.metadata, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) })]);
|
|
17
17
|
return requireProcess(db, id);
|
|
18
18
|
}
|
|
19
19
|
export function confirmManagedProcess(context, input) {
|
|
@@ -23,6 +23,10 @@ export function confirmManagedProcess(context, input) {
|
|
|
23
23
|
const metadata = parseMetadata(current.metadata_json);
|
|
24
24
|
if (input.processGroupId)
|
|
25
25
|
metadata.process_group_id = input.processGroupId;
|
|
26
|
+
if (input.ownerPid) {
|
|
27
|
+
metadata.owner_pid = input.ownerPid;
|
|
28
|
+
metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
|
|
29
|
+
}
|
|
26
30
|
const result = db.run(`UPDATE managed_processes
|
|
27
31
|
SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
|
|
28
32
|
WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
|
|
@@ -48,12 +52,15 @@ export function processIsAlive(record) {
|
|
|
48
52
|
try {
|
|
49
53
|
process.kill(record.pid, 0);
|
|
50
54
|
}
|
|
51
|
-
catch {
|
|
52
|
-
return
|
|
55
|
+
catch (error) {
|
|
56
|
+
return error.code !== "ESRCH";
|
|
53
57
|
}
|
|
54
|
-
|
|
58
|
+
const started = processStartedAt(record.pid);
|
|
59
|
+
return !record.pid_started_at || !started || record.pid_started_at === started;
|
|
55
60
|
}
|
|
56
|
-
function processTreeIsAlive(record) {
|
|
61
|
+
export function processTreeIsAlive(record) {
|
|
62
|
+
if (processIsAlive(record))
|
|
63
|
+
return true;
|
|
57
64
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
58
65
|
if (process.platform !== "win32" && typeof group === "number") {
|
|
59
66
|
try {
|
|
@@ -64,9 +71,19 @@ function processTreeIsAlive(record) {
|
|
|
64
71
|
return false;
|
|
65
72
|
}
|
|
66
73
|
}
|
|
67
|
-
return
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
export function managedOwnerIsAlive(record) {
|
|
77
|
+
const metadata = parseMetadata(record.metadata_json);
|
|
78
|
+
if (typeof metadata.owner_pid !== "number")
|
|
79
|
+
return null;
|
|
80
|
+
return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
|
|
68
81
|
}
|
|
69
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Expiry proves that observation stopped, not that the owner died. A live PID
|
|
84
|
+
* remains owned until its result is reconciled; only a dead process can be
|
|
85
|
+
* atomically claimed for cleanup.
|
|
86
|
+
*/
|
|
70
87
|
export function claimExpiredManagedProcesses(context, ownerId) {
|
|
71
88
|
const db = registry(context);
|
|
72
89
|
const now = context.now();
|
|
@@ -75,6 +92,9 @@ export function claimExpiredManagedProcesses(context, ownerId) {
|
|
|
75
92
|
const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]);
|
|
76
93
|
const claimed = [];
|
|
77
94
|
for (const candidate of candidates) {
|
|
95
|
+
const ownerAlive = managedOwnerIsAlive(candidate);
|
|
96
|
+
if (ownerAlive === true || (ownerAlive === null && processTreeIsAlive(candidate)))
|
|
97
|
+
continue;
|
|
78
98
|
const token = crypto.randomUUID();
|
|
79
99
|
const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
|
|
80
100
|
if (result.changes === 1)
|
|
@@ -150,7 +170,9 @@ function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart="
|
|
|
150
170
|
function releasePortClaims(db, claims) { for (const claim of claims)
|
|
151
171
|
db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
|
|
152
172
|
function terminateOwnedProcess(record, signal) {
|
|
153
|
-
|
|
173
|
+
// Never signal a group merely because its former leader PID was once ours.
|
|
174
|
+
// PID reuse or a departed leader makes group ownership unprovable.
|
|
175
|
+
if (!record.pid || !record.pid_started_at || !processIsAlive(record))
|
|
154
176
|
return;
|
|
155
177
|
const group = parseMetadata(record.metadata_json).process_group_id;
|
|
156
178
|
try {
|
|
@@ -102,7 +102,7 @@ async function dispatch(context, input) {
|
|
|
102
102
|
clearInterval(leaseHeartbeat);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
|
|
105
|
+
function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
|
|
106
106
|
return false; selected.add(request.project_id); return true; }).slice(0, limit); }
|
|
107
107
|
function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
|
|
108
108
|
const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
|
package/dist/services/runs.js
CHANGED
|
@@ -15,7 +15,7 @@ import { refreshRunSessionProjection } from "./run-projection.js";
|
|
|
15
15
|
import { resolveCanonRoot } from "./canon.js";
|
|
16
16
|
import { bindCurrentEngineToRun } from "./engines.js";
|
|
17
17
|
import { executionProfilePath, loadVnextExecutionProfile } from "./vnext-execution-profile.js";
|
|
18
|
-
import { isLegalVnextTransition, vnextStages } from "../domain/stage-catalog.js";
|
|
18
|
+
import { isLegalVnextTransition, vnextStageDirectory, vnextStages } from "../domain/stage-catalog.js";
|
|
19
19
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
20
20
|
const runSchemaId = "dd-flow/flow-run@3";
|
|
21
21
|
const runtimeSchemaId = "dd-flow/flow-run@3";
|
|
@@ -754,6 +754,66 @@ export function advanceFlowRun(context, input) {
|
|
|
754
754
|
const updatedRun = requireRunById(context, project.id, run.id);
|
|
755
755
|
return { ok: true, run: flowRunSummary(updatedRun), index, flow_guidance: guidanceForRun(context, updatedRun, index) };
|
|
756
756
|
}
|
|
757
|
+
/**
|
|
758
|
+
* The only legal backward edge in vNext: an integration gate exposed a
|
|
759
|
+
* product defect, so the source must be repaired and reviewed again. Normal
|
|
760
|
+
* stage start remains fail-closed for completed stages.
|
|
761
|
+
*/
|
|
762
|
+
export function prepareVnextMergeSourceRepairAttempt(context, input) {
|
|
763
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
764
|
+
const run = resolveRun(context, project.id, input.runId);
|
|
765
|
+
const index = authoritativeIndex(run);
|
|
766
|
+
const code = index.stage_runs.find((item) => item.stage === "code");
|
|
767
|
+
const review = index.stage_runs.find((item) => item.stage === "code-review");
|
|
768
|
+
const merge = index.stage_runs.find((item) => item.stage === "merge");
|
|
769
|
+
if (!code || code.status !== "done" || !review || review.status !== "done" || !merge || merge.status !== "running") {
|
|
770
|
+
throw new AppError("invalid_merge_repair_state", "Source repair requires accepted CODE/CODE-REVIEW and a running MERGE attempt", 2, {
|
|
771
|
+
run_id: run.id,
|
|
772
|
+
code: code?.status ?? null,
|
|
773
|
+
code_review: review?.status ?? null,
|
|
774
|
+
merge: merge?.status ?? null
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
const root = runArtifactRoot(run);
|
|
778
|
+
for (const stage of ["code", "code-review", "merge"])
|
|
779
|
+
archiveExistingStageAttempt(root, vnextStageDirectory(stage));
|
|
780
|
+
const now = context.now();
|
|
781
|
+
const restarted = {
|
|
782
|
+
...code,
|
|
783
|
+
status: "running",
|
|
784
|
+
started_at: now,
|
|
785
|
+
updated_at: now,
|
|
786
|
+
attempt: nextAttempt(code)
|
|
787
|
+
};
|
|
788
|
+
delete restarted.completed_at;
|
|
789
|
+
delete restarted.duration_ms;
|
|
790
|
+
delete restarted.stage_report;
|
|
791
|
+
delete restarted.data;
|
|
792
|
+
delete restarted.report;
|
|
793
|
+
delete restarted.artifact_aliases;
|
|
794
|
+
const cycle = `MRR-${input.mergeRequestId}`;
|
|
795
|
+
index.stage_runs = index.stage_runs.filter((item) => item.stage !== "code" && item.stage !== "code-review" && item.stage !== "merge");
|
|
796
|
+
index.stage_runs.push(restarted);
|
|
797
|
+
index.variables = { ...(index.variables ?? {}), "merge.source_repair": { cycle, merge_request_id: input.mergeRequestId, prepared_at: now } };
|
|
798
|
+
index.status = "running";
|
|
799
|
+
index.verdict = "merge_source_repair";
|
|
800
|
+
index.next_action = "start_code_repair";
|
|
801
|
+
index.current_stage = "code";
|
|
802
|
+
index.updated_at = now;
|
|
803
|
+
index.attempts = index.stage_runs.map((item) => ({
|
|
804
|
+
stage: item.stage,
|
|
805
|
+
attempt_number: Number((item.attempt ?? "try-001").replace("try-", "")) || 1,
|
|
806
|
+
root: item.dir,
|
|
807
|
+
archive: null,
|
|
808
|
+
status: item.status,
|
|
809
|
+
started_at: item.started_at ?? now,
|
|
810
|
+
finished_at: item.completed_at ?? null
|
|
811
|
+
}));
|
|
812
|
+
persistRunState(context, project, run, index);
|
|
813
|
+
appendAudit(context, { projectId: project.id, eventType: "flow_run.merge_source_repair_prepared", payload: { run_id: run.id, merge_request_id: input.mergeRequestId, cycle } });
|
|
814
|
+
appendRunTimeline(root, { at: now, type: "merge_source_repair_prepared", run_id: run.id, merge_request_id: input.mergeRequestId, cycle });
|
|
815
|
+
return { run_id: run.id, cycle };
|
|
816
|
+
}
|
|
757
817
|
function closeOpenStagesForOverride(index, status, now) {
|
|
758
818
|
const stageStatus = status === "cancelled" ? "skipped" : "failed";
|
|
759
819
|
for (const stage of index.stage_runs) {
|
|
@@ -48,10 +48,11 @@ export function startVnextCodeReview(context, input) {
|
|
|
48
48
|
return { ok: true, resumed: true, run_id: run.id, stage, stage_status: prior, id: binding.work_session_id, prompt_path: existingPrompt, worker_prompt_markdown: prompt, ...(externalContext ? { external_context: externalContext } : {}), ...(orchestration ? { orchestration } : {}), next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
|
|
49
49
|
}
|
|
50
50
|
const mode = effectiveMode(home, run);
|
|
51
|
+
const cycle = reviewCycle(run);
|
|
51
52
|
freezeFlowRunReviewMode(context, { projectRoot, runId: run.id, review: "code", mode: mode.mode, source: mode.source, reason: mode.reason });
|
|
52
53
|
const promptPath = path.join(root, "stage-prompt.md");
|
|
53
54
|
const reviewContextPath = path.join(root, "review-context.json");
|
|
54
|
-
fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, workspace_fingerprint: workspaceFingerprint(run.workspace_root), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
|
|
55
|
+
fs.writeFileSync(reviewContextPath, `${JSON.stringify({ schema_id: "dd-flow/code-review-context@1", cycle, mode: mode.mode, mode_source: mode.source, mode_reason: mode.reason, workspace_fingerprint: workspaceFingerprint(run.workspace_root), code_report_sha256: sha256File(path.join(home, "05-code", "stage-report.json")) }, null, 2)}\n`);
|
|
55
56
|
if (mode.mode === "off") {
|
|
56
57
|
const prompt = `<stage_identity>\n- RUN: ${run.id}\n- stage: code-review\n- mode: off\n</stage_identity>\n\nCODE-REVIEW is disabled by the frozen RUN configuration. Finish with: ${finishCommand(context, run.id, projectRoot, path.join(root, "decision.json"))}\n`;
|
|
57
58
|
fs.writeFileSync(promptPath, prompt);
|
|
@@ -61,7 +62,7 @@ export function startVnextCodeReview(context, input) {
|
|
|
61
62
|
return { ok: true, run_id: run.id, stage, mode: mode.mode, mode_source: mode.source, id: binding.work_session_id, prompt_path: promptPath, worker_prompt_markdown: fs.readFileSync(promptPath, "utf8"), ...(externalContext ? { external_context: externalContext } : {}), next: { finish_command: finishCommand(context, run.id, projectRoot, path.join(root, "decision.json")) } };
|
|
62
63
|
}
|
|
63
64
|
const groups = reviewGroups(home);
|
|
64
|
-
const batch = { works: groups.map((group, index) => ({ key: `code-review-${index + 1}`, task: reviewerTask(home, group), launch_policy: "fresh_agent_required", result_schema: "dd-flow/code-review-result@1", payload: { kind: "code-review", group, read_only: true } })) };
|
|
65
|
+
const batch = { works: groups.map((group, index) => ({ key: `code-review-${index + 1}`, task: reviewerTask(home, group), launch_policy: "fresh_agent_required", result_schema: "dd-flow/code-review-result@1", payload: { kind: "code-review", review_cycle: cycle, group, read_only: true } })) };
|
|
65
66
|
const batchFile = path.join(root, "review-work-batch.json");
|
|
66
67
|
fs.writeFileSync(batchFile, `${JSON.stringify(batch, null, 2)}\n`);
|
|
67
68
|
const prompt = orchestratorPrompt(context, { projectRoot, run, root, rootWork, mode: mode.mode, groups });
|
|
@@ -79,7 +80,7 @@ export function addVnextCodeReviewRepair(context, input) {
|
|
|
79
80
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
80
81
|
const project = requireProjectByRoot(context, projectRoot);
|
|
81
82
|
const run = requireRun(context, project.id, input.runId);
|
|
82
|
-
const reviewers = reviewerWorks(context, project.id, run.id);
|
|
83
|
+
const reviewers = reviewerWorks(context, project.id, run.id, reviewCycle(run));
|
|
83
84
|
const findings = canonicalCodeFindings(reviewers);
|
|
84
85
|
const selected = findings.filter((finding) => input.findingIds.includes(finding.finding_ref));
|
|
85
86
|
if (selected.length !== new Set(input.findingIds).size)
|
|
@@ -108,7 +109,8 @@ export function addVnextCodeReviewRepair(context, input) {
|
|
|
108
109
|
reviewFindings: selected.map(({ finding_ref, finding }) => ({ finding_ref, priority: finding.priority, problem: finding.problem, impact: finding.impact, required_outcome: finding.required_outcome, evidence_refs: finding.evidence_refs, obligation_refs: finding.obligation_refs, decision_reason: input.decisionReasonsByFinding?.[finding_ref] ?? input.objective, check_refs: input.checkRefsByFinding[finding_ref] ?? [] })),
|
|
109
110
|
reviewChecks,
|
|
110
111
|
originWorkIds: origins,
|
|
111
|
-
objective: input.objective
|
|
112
|
+
objective: input.objective,
|
|
113
|
+
reviewCycle: reviewCycle(run)
|
|
112
114
|
});
|
|
113
115
|
}
|
|
114
116
|
export async function finishVnextCodeReview(context, input) {
|
|
@@ -140,7 +142,8 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
140
142
|
fs.writeFileSync(decisionFile, `${JSON.stringify({ schema_id: "dd-flow/code-review-decision@3", summary: "CODE-REVIEW is disabled by RUN configuration.", findings: [] }, null, 2)}\n`);
|
|
141
143
|
validateSchema({ schemaName: "code-review-decision", file: decisionFile, projectRoot: run.workspace_root, ddFlowHome: context.ddFlowHome, runId: run.id, runRoot: home });
|
|
142
144
|
const decision = readJson(decisionFile);
|
|
143
|
-
const
|
|
145
|
+
const cycle = reviewContext.cycle ?? "initial";
|
|
146
|
+
const reviewers = reviewerWorks(context, project.id, run.id, cycle);
|
|
144
147
|
if (mode !== "off") {
|
|
145
148
|
const incomplete = reviewers.filter((work) => work.status !== "completed");
|
|
146
149
|
if (incomplete.length)
|
|
@@ -149,7 +152,7 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
149
152
|
validateReviewerResult(context, work, run);
|
|
150
153
|
const canonical = reviewDecisionForRepair(decision, reviewers);
|
|
151
154
|
const checkRefsByFinding = validateRepairChecks(context, { projectId: project.id, runId: run.id, decision: canonical.decision });
|
|
152
|
-
let repairs = reviewRepairWorks(context, project.id, run.id, root);
|
|
155
|
+
let repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
153
156
|
const decisionSha = sha256File(decisionFile);
|
|
154
157
|
const frozenShaFile = path.join(root, ".decision-sha256");
|
|
155
158
|
if (fs.existsSync(frozenShaFile) && fs.readFileSync(frozenShaFile, "utf8").trim() !== decisionSha)
|
|
@@ -160,14 +163,14 @@ export async function finishVnextCodeReview(context, input) {
|
|
|
160
163
|
fs.writeFileSync(frozenShaFile, `${decisionSha}\n`);
|
|
161
164
|
return { ok: true, run_id: run.id, stage, outcome: "repair_required", decision_sha256: decisionSha, repair, instruction: "Run the returned repair Work in one fresh child session. When it completes, invoke the same stage finish command again with the unchanged decision file.", next: { finish_command: finishCommand(context, run.id, projectRoot, decisionFile) } };
|
|
162
165
|
}
|
|
163
|
-
repairs = reviewRepairWorks(context, project.id, run.id, root);
|
|
166
|
+
repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
164
167
|
const incompleteRepairs = repairs.filter((work) => work.status !== "completed");
|
|
165
168
|
if (incompleteRepairs.length)
|
|
166
169
|
throw new AppError("review_repair_incomplete", "CODE-REVIEW cannot finish while a repair Work is unsettled", 2, { works: incompleteRepairs.map((work) => ({ work_id: work.work_id, status: work.status })) });
|
|
167
170
|
validateDecision(context, { decision: canonical.decision, reviewers: canonical.reviewers, repairs, workspaceRoot: run.workspace_root });
|
|
168
171
|
}
|
|
169
172
|
const outcome = decision.findings.some((item) => item.disposition === "defer") ? "accepted_with_DEF" : "accepted";
|
|
170
|
-
const repairs = reviewRepairWorks(context, project.id, run.id, root);
|
|
173
|
+
const repairs = reviewRepairWorks(context, project.id, run.id, root, cycle);
|
|
171
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 : []; }));
|
|
172
175
|
const unchangedFailures = unchangedFinalGateFailures(context, { projectId: project.id, runId: run.id, workspaceRoot: run.workspace_root, declarations: finalChecks });
|
|
173
176
|
if (unchangedFailures.length)
|
|
@@ -320,7 +323,7 @@ function validateReviewerResult(_context, work, _run) {
|
|
|
320
323
|
if (result.aspects.some((aspect) => aspect.verdict === "blocked"))
|
|
321
324
|
throw new AppError("review_blocked", "A blocked review aspect must be resolved before CODE-REVIEW can finish", 2, { work_id: work.work_id });
|
|
322
325
|
}
|
|
323
|
-
function reviewerWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const
|
|
326
|
+
function reviewerWorks(context, projectId, runId, cycle) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const payload = readJsonString(work.payload_json); return Boolean(payload.kind === "code-review" && (typeof payload.review_cycle === "string" ? payload.review_cycle : "initial") === cycle); }); }
|
|
324
327
|
function codeWorks(context, projectId, runId) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? AND status = 'completed' ORDER BY created_at, work_id", [projectId, runId]).filter((work) => readJsonString(work.payload_json).schema_id === "dd-flow/code-work-packet@5" && !reviewFindingIds(work).length); }
|
|
325
328
|
function acceptedCodeChecks(context, projectId, runId) { return codeWorks(context, projectId, runId).flatMap((work) => { const payload = readJsonString(work.payload_json); return Array.isArray(payload.checks) ? payload.checks : []; }); }
|
|
326
329
|
function validateRepairChecks(context, input) { const known = new Set(acceptedCodeChecks(context, input.projectId, input.runId).map((check) => check.id)); const selected = {}; for (const item of input.decision.findings.filter((item) => item.disposition === "fix")) {
|
|
@@ -332,7 +335,7 @@ function validateRepairChecks(context, input) { const known = new Set(acceptedCo
|
|
|
332
335
|
throw new AppError("review_check_reference_unknown", "CODE-REVIEW repair selects a check absent from the accepted CODE handoff", 2, { finding_ref: item.finding_ref, check_refs: unknown });
|
|
333
336
|
selected[item.finding_ref] = refs;
|
|
334
337
|
} return selected; }
|
|
335
|
-
function reviewRepairWorks(context, projectId, runId, root) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => isCodeReviewStageRepair(work, root)); }
|
|
338
|
+
function reviewRepairWorks(context, projectId, runId, root, cycle) { return context.db.all("SELECT * FROM works WHERE project_id = ? AND run_id = ? ORDER BY created_at, work_id", [projectId, runId]).filter((work) => { const payload = readJsonString(work.payload_json); return isCodeReviewStageRepair(work, root) && (typeof payload.review_cycle === "string" ? payload.review_cycle : "initial") === cycle; }); }
|
|
336
339
|
export function isCodeReviewStageRepair(work, root) {
|
|
337
340
|
if (reviewFindingIds(work).length)
|
|
338
341
|
return true;
|
|
@@ -350,7 +353,8 @@ function reviewFindingIds(work) { const repair = readJsonString(work.payload_jso
|
|
|
350
353
|
function reviewCheckRefs(work) { const repair = readJsonString(work.payload_json).repair; if (!repair || typeof repair !== "object" || Array.isArray(repair))
|
|
351
354
|
return []; const findings = repair.review_findings; return Array.isArray(findings) ? [...new Set(findings.flatMap((value) => typeof value === "object" && value !== null && Array.isArray(value.check_refs) ? value.check_refs.filter((ref) => typeof ref === "string") : []))] : []; }
|
|
352
355
|
function canonicalCodeFindings(works) { return works.flatMap((work) => readJsonString(work.result).findings.map((finding) => ({ finding_ref: `${work.work_id}/${finding.finding_id}`, finding }))); }
|
|
353
|
-
function effectiveMode(home, run) { const index = JSON.parse(run.index_json);
|
|
356
|
+
function effectiveMode(home, run) { const index = JSON.parse(run.index_json); if (index.variables?.["merge.source_repair"])
|
|
357
|
+
return { mode: "standard", source: "project_policy", reason: "A failed integration gate requires independent review of its source repair." }; const configured = index.settings?.code_review; const requested = configured?.mode ?? "auto"; if (requested !== "auto")
|
|
354
358
|
return { mode: requested, source: configured?.source === "user_instruction" ? "user_instruction" : "project_policy", reason: configured?.reason ?? "Frozen RUN configuration." }; const assessments = findFiles(path.join(home, "03-plan"), "plan.json").map((file) => readJson(file)); const deep = assessments.some((plan) => plan.assessment?.failure_impact?.level === "high" || plan.assessment?.solution_uncertainty?.level === "high"); return { mode: deep ? "deep" : "standard", source: "plan_assessment", reason: deep ? "Accepted PLAN assessment records high impact or uncertainty." : "Accepted PLAN assessment requires the standard independent review." }; }
|
|
355
359
|
function executionStopTarget(run) { return JSON.parse(run.index_json).execution_profile?.settings?.stop_target ?? "code_review_completed"; }
|
|
356
360
|
function changedPaths(workspaceRoot) { try {
|
|
@@ -366,6 +370,14 @@ function stageStatus(run, name) { try {
|
|
|
366
370
|
catch {
|
|
367
371
|
return null;
|
|
368
372
|
} }
|
|
373
|
+
function reviewCycle(run) { try {
|
|
374
|
+
const value = JSON.parse(run.index_json);
|
|
375
|
+
const repair = value.variables?.["merge.source_repair"];
|
|
376
|
+
return repair && typeof repair === "object" && !Array.isArray(repair) && typeof repair.cycle === "string" ? String(repair.cycle) : "initial";
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return "initial";
|
|
380
|
+
} }
|
|
369
381
|
function stageStartedAt(run, fallback) { try {
|
|
370
382
|
return JSON.parse(run.index_json).stage_runs?.find((item) => item.stage === stage)?.started_at ?? fallback;
|
|
371
383
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
3
3
|
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,
|
|
7
|
+
import { aggregateCheckDeclarations, checkReceipts, finalCodeCheckDeclarations, runCodeChecks, unchangedFinalGateFailures, 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";
|
|
@@ -31,6 +31,7 @@ export async function startVnextCode(context, input) {
|
|
|
31
31
|
const existingRoot = path.join(home, stageDir);
|
|
32
32
|
const existingPrompt = path.join(existingRoot, "stage-prompt.md");
|
|
33
33
|
const existingStage = stageStatus(run, stage);
|
|
34
|
+
const sourceRepairAttempt = isMergeSourceRepairAttempt(run);
|
|
34
35
|
if (existingStage && fs.existsSync(existingPrompt)) {
|
|
35
36
|
const externalContext = applyExternalStageContext({ stageRoot: existingRoot, promptPath: existingPrompt, ...(input.externalContext ? { loaded: input.externalContext } : {}) });
|
|
36
37
|
const prompt = fs.readFileSync(existingPrompt, "utf8");
|
|
@@ -73,14 +74,16 @@ export async function startVnextCode(context, input) {
|
|
|
73
74
|
resultPath: path.join(root, "stage-report.json"),
|
|
74
75
|
...(input.contextSha256 ? { contextSha256: input.contextSha256 } : {})
|
|
75
76
|
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
if (!sourceRepairAttempt) {
|
|
78
|
+
attachFlowRunStage(context, {
|
|
79
|
+
projectRoot,
|
|
80
|
+
runId: input.runId,
|
|
81
|
+
stage,
|
|
82
|
+
dir: stageDir,
|
|
83
|
+
status: "running",
|
|
84
|
+
dataSchemaId: "dd-flow/stage-report@1"
|
|
85
|
+
});
|
|
86
|
+
}
|
|
84
87
|
const graph = codeWorkGraph(context, project.id, input.runId);
|
|
85
88
|
const orchestration = writeFanoutDescriptor(root, { stage, parent_work_id: rootWork.work_id, dispatch: "none", capacity_required: true });
|
|
86
89
|
appendFlowRunTimelineEvent(context, project.id, input.runId, { type: "code_started", work_id: rootWork.work_id, graph });
|
|
@@ -357,7 +360,8 @@ export function addVnextCodeRepair(context, input) {
|
|
|
357
360
|
...(receipt ? ["Stop and report the blocker if the proposed repair contradicts an accepted requirement or non-goal."] : ["Do not modify accepted PLAN artifacts, code-work-batch.json, or review decisions. Stop and report if the finding cannot be repaired within accepted scope."])
|
|
358
361
|
]),
|
|
359
362
|
depends_on: origins.map((work) => work.work_id),
|
|
360
|
-
result_schema: "dd-flow/code-work-result@
|
|
363
|
+
result_schema: "dd-flow/code-work-result@3",
|
|
364
|
+
...(input.reviewCycle ? { review_cycle: input.reviewCycle } : {})
|
|
361
365
|
};
|
|
362
366
|
const file = path.join(home, stageDir, `.repair-${crypto.randomUUID()}.json`);
|
|
363
367
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -545,8 +549,8 @@ export function verificationProjection(works, receipts, options = {}) {
|
|
|
545
549
|
for (const work of works) {
|
|
546
550
|
const result = readJsonResult(work.result);
|
|
547
551
|
for (const item of result.evidence ?? [])
|
|
548
|
-
if (item.
|
|
549
|
-
rawEvidence.set(item.
|
|
552
|
+
if (item.obligation_ref)
|
|
553
|
+
rawEvidence.set(item.obligation_ref, unique([...(rawEvidence.get(item.obligation_ref) ?? []), ...(item.refs ?? [])]));
|
|
550
554
|
}
|
|
551
555
|
const acceptance = uniqueBy(packets.flatMap((value) => value.acceptance), (value) => value.criterion_id);
|
|
552
556
|
const historicalReceiptRefs = new Set((options.historicalReceipts ?? receipts).flatMap((receipt) => receiptReferences(receipt, options)));
|
|
@@ -649,50 +653,22 @@ async function ensureCodeWorkspaceReady(context, projectId, run, root, readiness
|
|
|
649
653
|
throw new AppError("execution_profile_invalid", "CODE requires a frozen bootstrap command in the RUN execution profile", 1, { run_id: run.id });
|
|
650
654
|
}
|
|
651
655
|
const receiptPath = path.join(root, "workspace-readiness.json");
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
return { ...prior, reused: true };
|
|
656
|
+
// Re-run the project bootstrap on stage entry: a prior receipt cannot prove
|
|
657
|
+
// that ignored dependencies or local services still exist.
|
|
655
658
|
const startedAt = new Date().toISOString();
|
|
656
659
|
progress?.(`workspace bootstrap started: ${bootstrap.command}`);
|
|
657
|
-
const result = await
|
|
660
|
+
const result = await runWorkspaceBootstrap(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, command: bootstrap.command, artifactDir: "05-code/readiness", progress });
|
|
658
661
|
const bootstrapPassed = result.exit_code === 0 && !result.error;
|
|
659
662
|
const checks = bootstrapPassed && readinessChecks.length
|
|
660
663
|
? await runCodeChecks(context, { projectId, runId: run.id, runHome: requireHome(run), workspaceRoot: run.workspace_root, scope: "aggregate", artifactDir: "05-code/readiness", checks: readinessChecks, ...(progress ? { progress } : {}) })
|
|
661
664
|
: [];
|
|
662
|
-
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
665
|
+
const receipt = { schema_id: "dd-flow/workspace-readiness@2", workspace_root: run.workspace_root, command: bootstrap.command, policy_ref: bootstrap.policy_ref, started_at: startedAt, finished_at: new Date().toISOString(), status: bootstrapPassed && checks.every((check) => check.status === "passed") ? "passed" : "failed", process_id: result.process_id, stdout_path: result.stdout_path, stderr_path: result.stderr_path, completion_path: result.completion_path, exit_code: result.exit_code, stdout: result.stdout, stderr: result.stderr, check_declarations: readinessChecks, checks: checks.map((check) => ({ id: check.id, status: check.status, receipt_path: check.receipt_path })), ...(result.error ? { error: result.error } : {}), receipt_path: receiptPath };
|
|
663
666
|
fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
664
667
|
if (receipt.status !== "passed")
|
|
665
668
|
throw new AppError("workspace_readiness_failed", "CODE workspace bootstrap failed", 1, receipt);
|
|
666
669
|
progress?.("workspace bootstrap passed");
|
|
667
670
|
return receipt;
|
|
668
671
|
}
|
|
669
|
-
function runBootstrap(command, cwd, environment, progress) {
|
|
670
|
-
return new Promise((resolve) => {
|
|
671
|
-
const started = Date.now();
|
|
672
|
-
const child = spawn("/bin/sh", ["-lc", command], { cwd, env: environment, stdio: ["ignore", "pipe", "pipe"] });
|
|
673
|
-
let stdout = "";
|
|
674
|
-
let stderr = "";
|
|
675
|
-
let error = null;
|
|
676
|
-
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
|
|
677
|
-
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
|
|
678
|
-
const heartbeat = setInterval(() => progress?.(`workspace bootstrap still running (${Math.floor((Date.now() - started) / 1000)}s)`), 15_000);
|
|
679
|
-
const timeout = setTimeout(() => child.kill("SIGTERM"), 15 * 60 * 1000);
|
|
680
|
-
child.once("error", (value) => { error = value.message; });
|
|
681
|
-
child.once("close", (exitCode, signal) => {
|
|
682
|
-
clearInterval(heartbeat);
|
|
683
|
-
clearTimeout(timeout);
|
|
684
|
-
resolve({ exit_code: exitCode, stdout, stderr, error: error ?? (signal ? `terminated by ${signal}` : null) });
|
|
685
|
-
});
|
|
686
|
-
});
|
|
687
|
-
}
|
|
688
|
-
function readReadiness(file) {
|
|
689
|
-
try {
|
|
690
|
-
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
691
|
-
}
|
|
692
|
-
catch {
|
|
693
|
-
return null;
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
672
|
function requireHome(run) {
|
|
697
673
|
if (!run.run_root)
|
|
698
674
|
throw new AppError("runtime_missing", "RUN artifact root is unavailable", 1);
|
|
@@ -726,6 +702,8 @@ export function writeProtocolFlowStatus(workspaceRoot, runHome, runId, status) {
|
|
|
726
702
|
}
|
|
727
703
|
function nextAction(run, changedPaths) {
|
|
728
704
|
const index = JSON.parse(run.index_json);
|
|
705
|
+
if (index.variables?.["merge.source_repair"])
|
|
706
|
+
return "start_code_review";
|
|
729
707
|
const requested = index.settings?.code_review?.mode ?? "auto";
|
|
730
708
|
const enabled = requested === "off" ? false : requested === "auto" ? changedPaths.length > 0 : true;
|
|
731
709
|
const stopTarget = index.execution_profile?.settings?.stop_target;
|
|
@@ -741,6 +719,15 @@ function stageStatus(run, name) {
|
|
|
741
719
|
return null;
|
|
742
720
|
}
|
|
743
721
|
}
|
|
722
|
+
function isMergeSourceRepairAttempt(run) {
|
|
723
|
+
try {
|
|
724
|
+
const value = JSON.parse(run.index_json);
|
|
725
|
+
return Boolean(value.variables?.["merge.source_repair"]);
|
|
726
|
+
}
|
|
727
|
+
catch {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
744
731
|
function changedPathsFromReport(file) {
|
|
745
732
|
try {
|
|
746
733
|
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { runWorkspaceBootstrap } from "./workspace-bootstrap.js";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -8,11 +9,12 @@ import { resolveProjectRoot } from "../storage/paths.js";
|
|
|
8
9
|
import { requireProjectByRoot } from "./projects.js";
|
|
9
10
|
import { nextMergeRequestId } from "./ids.js";
|
|
10
11
|
import { readProjectConfig } from "./config.js";
|
|
11
|
-
import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts } from "./runs.js";
|
|
12
|
+
import { appendFlowRunTimelineEvent, attachFlowRunStage, completeFlowRun, completeFlowRunStage, gitFacts, prepareVnextMergeSourceRepairAttempt } from "./runs.js";
|
|
12
13
|
import { flowCommand, stagePauseCommandTemplate } from "./stage-pause.js";
|
|
13
14
|
import { validateSchema } from "./schema-validation.js";
|
|
14
15
|
import { writeStageReport } from "./stage-report-renderer.js";
|
|
15
|
-
import { bindStageCoordinatorWork, createChildWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
|
|
16
|
+
import { bindStageCoordinatorWork, createChildWork, failWork, finishFanInWork, finishWork, refreshRunWorkProjection, startStageCoordinatorWork } from "./work-registry.js";
|
|
17
|
+
import { addVnextCodeRepair } from "./vnext-code.js";
|
|
16
18
|
import { checkReceipts, effectiveCheckDeclarations, readCodeCheckProfile, runCodeChecks, unchangedFinalGateFailures, workspaceFingerprint } from "./code-checks.js";
|
|
17
19
|
import { applyExternalStageContext } from "./stage-context.js";
|
|
18
20
|
import { ensureLaneWorkspace, heartbeatLaneLock, releaseLaneLock, waitAcquireLaneLock } from "./lanes.js";
|
|
@@ -27,7 +29,7 @@ export function ensureVnextMergeRequest(context, input) {
|
|
|
27
29
|
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
28
30
|
const project = requireProjectByRoot(context, projectRoot);
|
|
29
31
|
const run = requireRun(context, project.id, input.runId);
|
|
30
|
-
const existing = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at LIMIT 1", [project.id, run.id]);
|
|
32
|
+
const existing = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') ORDER BY created_at DESC LIMIT 1", [project.id, run.id]);
|
|
31
33
|
if (existing)
|
|
32
34
|
return requestView(context, existing);
|
|
33
35
|
const settings = executionSettings(run);
|
|
@@ -88,14 +90,14 @@ export function ensureVnextMergeRequest(context, input) {
|
|
|
88
90
|
const now = context.now();
|
|
89
91
|
context.db.exec("BEGIN IMMEDIATE");
|
|
90
92
|
try {
|
|
91
|
-
const raced = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ?", [project.id, run.id]);
|
|
93
|
+
const raced = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') ORDER BY created_at DESC LIMIT 1", [project.id, run.id]);
|
|
92
94
|
if (raced) {
|
|
93
95
|
context.db.exec("COMMIT");
|
|
94
96
|
return requestView(context, raced);
|
|
95
97
|
}
|
|
96
98
|
child = createChildWork(context, { parentWorkId: root.work_id, slug: "merge", task: `Integrate frozen source ${sourceCommit} into ${targetBranch}, resolve material conflicts, and preserve accepted behavior.`, launchPolicy: route === "server" ? "fresh_agent_required" : "reuse_allowed", resultSchema: "dd-flow/merge-result@1", payload: { kind: "merge", checks: semantic, protocol_ids: protocols } });
|
|
97
99
|
requestId = nextMergeRequestId(context);
|
|
98
|
-
context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, now, now]);
|
|
100
|
+
context.db.run("INSERT INTO merge_requests (merge_request_id, project_id, run_id, executor_work_id, protocol_ids_json, source_workspace, source_branch, source_commit, target_workspace, target_branch, enqueue_target_head, execution_target_head, integration_commit, execution_route, status, dispatch_owner, dispatch_lease_token, dispatch_lease_expires_at, lock_acquired_at, checkpoint, profile_hash, replacement_of_merge_request_id, adapter_receipt_json, result_json, last_error_json, created_at, updated_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, 'queued', NULL, NULL, NULL, NULL, 'queued', ?, ?, NULL, NULL, NULL, ?, ?, NULL)", [requestId, project.id, run.id, child.work_id, JSON.stringify(protocols), run.workspace_root, sourceBranch, sourceCommit, targetWorkspace, targetBranch, enqueueTarget, route, readCodeCheckProfile(targetWorkspace).hash, replacementOf(run), now, now]);
|
|
99
101
|
context.db.exec("COMMIT");
|
|
100
102
|
}
|
|
101
103
|
catch (error) {
|
|
@@ -219,7 +221,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
219
221
|
throw new AppError("merge_semantic_blocked", "A blocked semantic result cannot complete MERGE", 2, { result_path: resultPath });
|
|
220
222
|
if (request.checkpoint === "apply_recorded") {
|
|
221
223
|
input.progress?.("bootstrapping integrated target");
|
|
222
|
-
runBootstrap(run, request.target_workspace);
|
|
224
|
+
await runBootstrap(context, run, request.target_workspace, request.merge_request_id, input.progress);
|
|
223
225
|
context.db.run("UPDATE merge_requests SET checkpoint = 'bootstrap_ready', updated_at = ? WHERE merge_request_id = ?", [context.now(), request.merge_request_id]);
|
|
224
226
|
request = requireRequest(context, request.merge_request_id);
|
|
225
227
|
}
|
|
@@ -231,7 +233,7 @@ export async function finishVnextMerge(context, input) {
|
|
|
231
233
|
const failed = receipts.filter((receipt) => receipt.status !== "passed");
|
|
232
234
|
if (failed.length) {
|
|
233
235
|
context.db.run("UPDATE merge_requests SET status = 'action_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "merge_gate_failed", failures: failed }), context.now(), request.merge_request_id]);
|
|
234
|
-
throw new AppError("
|
|
236
|
+
throw new AppError("merge_source_repair_required", "Integrated target checks failed. Do not edit the integration workspace: run the returned source-repair command.", 2, { failures: failed, repair_command: `${flowCommand(context)} merge repair ${request.merge_request_id} --project-root ${JSON.stringify(projectRoot)} --json` });
|
|
235
237
|
}
|
|
236
238
|
const requiredRefs = mergeAcceptanceRefs(run.workspace_root, JSON.parse(request.protocol_ids_json));
|
|
237
239
|
const passedRefs = new Set(receipts.flatMap((receipt) => receipt.check_refs));
|
|
@@ -303,7 +305,33 @@ catch (error) {
|
|
|
303
305
|
context.db.exec("ROLLBACK");
|
|
304
306
|
throw error;
|
|
305
307
|
} refreshRunWorkProjection(context, request.project_id, request.run_id); return requestView(context, requireRequest(context, request.merge_request_id)); }
|
|
306
|
-
|
|
308
|
+
/** Move a failed integration gate back to the source CODE/CODE-REVIEW loop. */
|
|
309
|
+
export async function repairVnextMerge(context, input) {
|
|
310
|
+
const projectRoot = resolveProjectRoot(input.projectRoot);
|
|
311
|
+
const project = requireProjectByRoot(context, projectRoot);
|
|
312
|
+
const request = requireRequest(context, input.requestId);
|
|
313
|
+
if (request.project_id !== project.id || request.status !== "action_required" || errorCode(request) !== "merge_gate_failed") {
|
|
314
|
+
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) });
|
|
315
|
+
}
|
|
316
|
+
abortIntegration(request);
|
|
317
|
+
const originWorkIds = sourceCodeWorkIds(context, request.project_id, request.run_id);
|
|
318
|
+
if (!originWorkIds.length)
|
|
319
|
+
throw new AppError("runtime_missing", "MERGE source repair requires accepted CODE Work evidence", 1, { run_id: request.run_id });
|
|
320
|
+
const failed = failedReceiptIds(request);
|
|
321
|
+
if (!failed.length)
|
|
322
|
+
throw new AppError("runtime_missing", "MERGE failure has no durable failed receipt", 1, { merge_request_id: request.merge_request_id });
|
|
323
|
+
// Settle the child while its canonical result path still exists; the next
|
|
324
|
+
// step archives the entire MERGE attempt as immutable evidence.
|
|
325
|
+
await failWork(context, request.executor_work_id, `Superseded by source repair after failed integration gate ${failed[0]}.`);
|
|
326
|
+
const prepared = prepareVnextMergeSourceRepairAttempt(context, { projectRoot, runId: request.run_id, mergeRequestId: request.merge_request_id });
|
|
327
|
+
const repair = addVnextCodeRepair(context, { projectRoot, runId: request.run_id, checkReceiptId: failed[0], originWorkIds, objective: `Repair source behavior evidenced by failed integration check ${failed[0]}.` });
|
|
328
|
+
const now = context.now();
|
|
329
|
+
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]);
|
|
330
|
+
releaseMergeLane(context, projectRoot, request, `MERGE ${request.merge_request_id} moved to source repair`);
|
|
331
|
+
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 });
|
|
332
|
+
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` } };
|
|
333
|
+
}
|
|
334
|
+
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, inspect only the returned receipt/logs, then run this exact source-repair command: ${repair}. It restores the integration target to baseline and creates a CODE → independent CODE-REVIEW → replacement MRG cycle. Do not repair the integration target and do not retry this MRG.`, "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"); }
|
|
307
335
|
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" } }; }
|
|
308
336
|
function effectiveMergeChecks(run, request) { return readFrozenMergeGate(path.join(requireHome(run), stageDir, "merge-gate.json"), request.merge_request_id).checks; }
|
|
309
337
|
function planChecks(workspace, protocols) { return protocols.flatMap((protocol) => { const file = path.join(workspace, ".memory-bank", "protocol", protocol, "plan.json"); if (!fs.existsSync(file))
|
|
@@ -333,9 +361,14 @@ function protocolIds(home) { const root = path.join(home, "03-plan"); if (!fs.ex
|
|
|
333
361
|
function workspaceRoute(home) { const file = path.join(home, "02-protocolize", "workspace-route.json"); const value = JSON.parse(fs.readFileSync(file, "utf8")); if (!value.policy?.integration_branch)
|
|
334
362
|
throw new AppError("workspace_route_missing", "MERGE requires the frozen integration branch", 1, { file }); return { integration_branch: value.policy.integration_branch }; }
|
|
335
363
|
function executionSettings(run) { return JSON.parse(run.index_json).execution_profile?.settings ?? {}; }
|
|
336
|
-
function runBootstrap(run, cwd
|
|
337
|
-
|
|
338
|
-
|
|
364
|
+
async function runBootstrap(context, run, cwd, mergeId, progress) {
|
|
365
|
+
const command = executionSettings(run).code_bootstrap?.command;
|
|
366
|
+
if (!command)
|
|
367
|
+
throw new AppError("execution_profile_invalid", "MERGE requires a frozen workspace bootstrap command", 2);
|
|
368
|
+
const result = await runWorkspaceBootstrap(context, { projectId: run.project_id, runId: run.id, runHome: requireHome(run), workspaceRoot: cwd, command, artifactDir: `${stageDir}/${mergeId}/readiness`, progress });
|
|
369
|
+
if (result.exit_code !== 0 || result.error)
|
|
370
|
+
throw new AppError("merge_bootstrap_failed", "Integrated target bootstrap failed; inspect its retained logs", 2, { command, ...result });
|
|
371
|
+
}
|
|
339
372
|
function verifyLocalDelivery(request) { const head = gitValue(request.target_workspace, ["rev-parse", request.target_branch]); if (!request.integration_commit || head !== request.integration_commit)
|
|
340
373
|
throw new AppError("merge_delivery_unproven", "Local integration branch does not resolve to the accepted integration commit", 1, { target_branch: request.target_branch, expected: request.integration_commit, actual: head }); }
|
|
341
374
|
function cleanupReceiptPath(run) { return path.join(requireHome(run), stageDir, "cleanup-receipt.json"); }
|
|
@@ -365,7 +398,7 @@ function releaseMergeLane(context, projectRoot, request, reason) {
|
|
|
365
398
|
}
|
|
366
399
|
catch { /* An expired lease cannot invalidate an already completed MERGE. */ }
|
|
367
400
|
}
|
|
368
|
-
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') 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; }
|
|
401
|
+
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; }
|
|
369
402
|
function queueStatus(context, request) { return { position: queueAhead(context, request) + 1, requests_ahead: queueAhead(context, request), status: request.status, route: request.execution_route }; }
|
|
370
403
|
function requireOwnedActiveRequest(context, input) { const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot)); const request = requireRequest(context, input.requestId); if (request.project_id !== project.id || request.executor_work_id !== input.workId || !["active", "action_required"].includes(request.status))
|
|
371
404
|
throw new AppError("invalid_merge_state", "MRG/Work/project is not the active integration owner", 2, { merge_request_id: input.requestId, work_id: input.workId, status: request.status }); return request; }
|
|
@@ -392,11 +425,42 @@ catch (error) {
|
|
|
392
425
|
function gitValue(cwd, args, allowFailure = false) { const result = spawnSync("git", args, { cwd, encoding: "utf8" }); if (result.status !== 0 && !allowFailure)
|
|
393
426
|
throw new AppError("git_operation_failed", `git ${args[0]} failed`, 1, { cwd, args, stderr: result.stderr }); return result.status === 0 ? result.stdout.trim() : ""; }
|
|
394
427
|
function applyReceiptPath(context, request) { const run = requireRun(context, request.project_id, request.run_id); return path.join(requireHome(run), stageDir, "works", request.executor_work_id, "apply-receipt.json"); }
|
|
395
|
-
function requestForRun(context, projectId, runId) { const request = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at LIMIT 1", [projectId, runId]); if (!request)
|
|
428
|
+
function requestForRun(context, projectId, runId) { const request = context.db.get("SELECT * FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC LIMIT 1", [projectId, runId]); if (!request)
|
|
396
429
|
throw new AppError("merge_request_missing", "MERGE request was not materialized by the prior terminal stage", 1, { run_id: runId }); return request; }
|
|
397
430
|
function requireRequest(context, id) { const request = context.db.get("SELECT * FROM merge_requests WHERE merge_request_id = ?", [id]); if (!request)
|
|
398
431
|
throw new AppError("not_found", "MERGE request is not registered", 1, { merge_request_id: id }); return request; }
|
|
399
|
-
function requestView(context, request) { return { ok: true, merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source: { workspace: request.source_workspace, branch: request.source_branch, commit: request.source_commit }, target: { workspace: request.target_workspace, branch: request.target_branch, enqueue_head: request.enqueue_target_head, execution_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit }, route: request.execution_route, status: request.status, checkpoint: request.checkpoint, queue: queueStatus(context, request), created_at: request.created_at, completed_at: request.completed_at }; }
|
|
432
|
+
function requestView(context, request) { return { ok: true, merge_request_id: request.merge_request_id, run_id: request.run_id, work_id: request.executor_work_id, protocols: JSON.parse(request.protocol_ids_json), source: { workspace: request.source_workspace, branch: request.source_branch, commit: request.source_commit }, target: { workspace: request.target_workspace, branch: request.target_branch, enqueue_head: request.enqueue_target_head, execution_head: request.execution_target_head, accepted_tree: request.accepted_tree, integration_commit: request.integration_commit }, route: request.execution_route, status: request.status, checkpoint: request.checkpoint, ...(request.replacement_of_merge_request_id ? { replacement_of_merge_request_id: request.replacement_of_merge_request_id } : {}), queue: queueStatus(context, request), created_at: request.created_at, completed_at: request.completed_at }; }
|
|
433
|
+
function replacementOf(run) { try {
|
|
434
|
+
const variables = JSON.parse(run.index_json).variables;
|
|
435
|
+
const repair = variables?.["merge.source_repair"];
|
|
436
|
+
return repair && typeof repair === "object" && !Array.isArray(repair) && typeof repair.merge_request_id === "string" ? String(repair.merge_request_id) : null;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return null;
|
|
440
|
+
} }
|
|
441
|
+
function errorCode(request) { try {
|
|
442
|
+
const value = JSON.parse(request.last_error_json ?? "{}");
|
|
443
|
+
return typeof value?.code === "string" ? value.code : null;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return null;
|
|
447
|
+
} }
|
|
448
|
+
function failedReceiptIds(request) { try {
|
|
449
|
+
const value = JSON.parse(request.last_error_json ?? "{}");
|
|
450
|
+
return (value.failures ?? []).flatMap((failure) => typeof failure === "object" && failure !== null && typeof failure.id === "string" ? [String(failure.id)] : []);
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return [];
|
|
454
|
+
} }
|
|
455
|
+
function sourceCodeWorkIds(context, projectId, runId) { return context.db.all("SELECT work_id, payload_json FROM works WHERE project_id = ? AND run_id = ? AND status = 'completed' ORDER BY created_at, work_id", [projectId, runId]).flatMap((work) => { try {
|
|
456
|
+
return JSON.parse(work.payload_json ?? "").schema_id === "dd-flow/code-work-packet@5" ? [work.work_id] : [];
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return [];
|
|
460
|
+
} }); }
|
|
461
|
+
function abortIntegration(request) { if (gitValue(request.target_workspace, ["rev-parse", "-q", "--verify", "MERGE_HEAD"], true))
|
|
462
|
+
git(request.target_workspace, ["merge", "--abort"]); const head = gitValue(request.target_workspace, ["rev-parse", "HEAD"]); const branch = gitValue(request.target_workspace, ["branch", "--show-current"]); const dirty = meaningfulStatus(request.target_workspace, []); if (head !== request.execution_target_head || branch !== request.target_branch || dirty.length)
|
|
463
|
+
throw new AppError("merge_recovery_required", "Integration workspace could not be restored to its locked baseline", 1, { merge_request_id: request.merge_request_id, expected_head: request.execution_target_head, head, expected_branch: request.target_branch, branch, dirty }); }
|
|
400
464
|
function requireRun(context, projectId, runId) { const run = context.db.get("SELECT id, project_id, project_root, workspace_root, run_root, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]); if (!run)
|
|
401
465
|
throw new AppError("not_found", "RUN is not registered", 1); return run; }
|
|
402
466
|
function requireHome(run) { if (!run.run_root)
|
|
@@ -431,7 +431,7 @@ function projectCodeWorkBatch(input) {
|
|
|
431
431
|
provides_checks: value.checks.filter((check) => check.availability === "planned" && check.provided_by === item.id),
|
|
432
432
|
stop_conditions: item.execution_context.stop_conditions,
|
|
433
433
|
depends_on: item.depends_on.map((dependency) => `${protocolId}:${dependency}`),
|
|
434
|
-
result_schema: "dd-flow/code-work-result@
|
|
434
|
+
result_schema: "dd-flow/code-work-result@3"
|
|
435
435
|
});
|
|
436
436
|
}));
|
|
437
437
|
const byProtocol = new Map(input.plans.map((plan) => [plan.protocolId, plan.value.items]));
|
|
@@ -428,7 +428,7 @@ function validateWorkResult(work, result, projectRoot, workspaceRoot, runHome, r
|
|
|
428
428
|
finally {
|
|
429
429
|
fs.rmSync(candidate, { force: true });
|
|
430
430
|
}
|
|
431
|
-
if (work.result_schema === "dd-flow/code-work-result@
|
|
431
|
+
if (work.result_schema === "dd-flow/code-work-result@3")
|
|
432
432
|
validateCodeWorkResult(work, parsed, workspaceRoot, runHome, runId);
|
|
433
433
|
if (work.result_schema === "dd-flow/code-review-result@1")
|
|
434
434
|
validateCodeReviewResult(work, parsed, { workspaceRoot, runHome, runId });
|
|
@@ -442,10 +442,15 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
|
|
|
442
442
|
const packet = codePacket(work);
|
|
443
443
|
if (!packet)
|
|
444
444
|
return;
|
|
445
|
-
const
|
|
445
|
+
const acceptedObligations = new Set(packet.acceptance.map((item) => item.criterion_id).filter((item) => typeof item === "string"));
|
|
446
|
+
for (const check of packet.checks)
|
|
447
|
+
acceptedObligations.add(check.id);
|
|
448
|
+
for (const finding of packet.repair?.review_findings ?? [])
|
|
449
|
+
for (const ref of finding.obligation_refs ?? [])
|
|
450
|
+
acceptedObligations.add(ref);
|
|
446
451
|
for (const item of result.evidence ?? []) {
|
|
447
|
-
if (!item.
|
|
448
|
-
throw new AppError("
|
|
452
|
+
if (!item.obligation_ref || !acceptedObligations.has(item.obligation_ref))
|
|
453
|
+
throw new AppError("evidence_obligation_unknown", "CODE Work evidence must reference an obligation assigned to this Work", 2, { work_id: work.work_id, obligation_ref: item.obligation_ref ?? null, accepted_obligation_refs: [...acceptedObligations] });
|
|
449
454
|
for (const ref of item.refs ?? [])
|
|
450
455
|
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
451
456
|
}
|
|
@@ -459,10 +464,10 @@ function validateCodeWorkResult(work, value, projectRoot, runHome, runId) {
|
|
|
459
464
|
if (unchangedDocuments.length)
|
|
460
465
|
throw new AppError("document_update_not_materialized", "Assigned durable document updates must exist and differ from their PLAN baseline", 2, { work_id: work.work_id, unchanged_paths: unchangedDocuments });
|
|
461
466
|
const assigned = packet.repair?.review_findings?.map((finding) => finding.finding_ref) ?? [];
|
|
467
|
+
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
462
468
|
if (assigned.length) {
|
|
463
469
|
if ((result.changed_paths?.length ?? 0) === 0)
|
|
464
470
|
throw new AppError("review_repair_no_change", "Review repair must materialize a project change; a no-op cannot resolve a finding", 2, { work_id: work.work_id, findings: assigned });
|
|
465
|
-
validateReviewResolutions(work.work_id, assigned, result.resolutions ?? []);
|
|
466
471
|
for (const resolution of result.resolutions ?? [])
|
|
467
472
|
for (const ref of resolution.evidence_refs ?? [])
|
|
468
473
|
assertPortableArtifactRef(ref, { workspaceRoot: projectRoot, runHome, runId });
|
|
@@ -479,22 +484,41 @@ export function validateReviewResolutions(workId, assigned, resolutions) {
|
|
|
479
484
|
function renderWorkerPrompt(context, work, run, dependencies) {
|
|
480
485
|
const command = flowCommand(context);
|
|
481
486
|
const packet = codePacket(work);
|
|
482
|
-
const
|
|
487
|
+
const mergeWork = parsePayload(work)?.kind === "merge";
|
|
488
|
+
const writeBoundary = mergeWork
|
|
489
|
+
? ["MERGE does not own product-code repair. Use only the stage packet's merge apply command; resolve only paths that Git reports as unmerged. Do not edit integration code, tests, documentation or configuration to make a gate pass.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result."]
|
|
490
|
+
: [`HARD RULE: project source reads and writes must remain under ${run.workspace_root}.`, "Do not write through project_root, outside workspace_root, into another RUN, or into Git/worktree control data. Do not create, switch, merge or delete branches/worktrees.", "RUN artifacts are read-only evidence: refer to them with run:// URIs and let dd-flow persist your submitted result. Accepted requirements, non-goals and stop_conditions are semantic hard boundaries. planned_write_areas is not."];
|
|
491
|
+
const completionRepair = mergeWork
|
|
492
|
+
? ["A failed integration check is evidence for a source-repair cycle, not permission to repair the integration workspace. Follow the exact merge repair command returned by dd-flow."]
|
|
493
|
+
: ["Read the failed receipt and its stdout/stderr. Fix project-owned source, migration, test, formatting, or configuration errors in this same Work, then call Finish again. Do not invent a cause that does not appear in the retained output."];
|
|
494
|
+
const codeContext = packet ? ["<semantic_spine>", JSON.stringify(packet.semantic_spine, null, 2), "</semantic_spine>", "", ...(packet.repair ? ["<repair_context>", JSON.stringify(packet.repair, null, 2), "Read the failed receipt and its linked stdout/stderr before editing. Preserve the accepted origin context and fix only the evidenced failure.", "</repair_context>", ""] : []), "<accepted_requirements>", JSON.stringify(packet.requirements, null, 2), "</accepted_requirements>", "", "<write_boundary_invariant>", "For a mutation guarded by membership, ownership, authorization or parent lifecycle state, preserve that predicate in the write statement or make guard and write one explicit transaction with the needed lock. A prior read may diagnose an error but never proves a later write remains allowed. Apply this to create, update, delete and parent-state mutations.", "</write_boundary_invariant>", "", "<acceptance_context>", "The criteria below are end-to-end context. Complete this Work's semantic contribution and declared checks; another ordered Work may own a different acceptance surface.", JSON.stringify(packet.acceptance, null, 2), "</acceptance_context>", "", "<required_read>", "These are mandatory starting sources, not a read allowlist. Read any additional project files needed to implement the Work correctly.", ...packet.required_read.map((item) => `- ${resolveRunReferences(item, work.run_id, requireRunHome(run))}`), "</required_read>", "", "<discovery_boundary>", "These are likely discovery areas, not a hard boundary. Expand project-local investigation when required and report material additions.", ...packet.discovery_boundary.map((item) => `- ${item}`), "</discovery_boundary>", "", "<planned_write_areas>", "SOFT COORDINATION HINT ONLY. These paths help the coordinator avoid concurrent collisions. They do not grant or deny write permission and do not limit the files needed for this Work. You may create or change any project file under workspace_root that is necessary and in semantic scope; report every actual changed path.", ...(packet.planned_write_areas.length ? packet.planned_write_areas.map((item) => `- ${item}`) : ["- none predicted; derive the necessary files from the task"]), "</planned_write_areas>", "", ...(packet.provides_checks.length ? ["<provided_checks>", ...packet.provides_checks.map((item) => `- ${item.id}: materialize ${item.command}${item.definition ? ` as ${item.definition}` : ""}; it is not usable until this Work finishes.`), "Update the declared project command or alias before Work finish. The CLI verifies materialization at Work finish. Execution occurs at its declared run_at gate, not necessarily in this Work.", "</provided_checks>", ""] : []), "<verification>", ...packet.checks.map((item) => `- ${item.id} at ${item.run_at}: ${item.command} — ${item.purpose}`), "The CLI executes work-scoped checks and retains their receipts. Report semantic evidence only; do not rerun declared checks manually.", "</verification>", "", "<stop_conditions>", ...packet.stop_conditions.map((item) => `- ${item}`), "</stop_conditions>", ""] : [];
|
|
483
495
|
if (packet)
|
|
484
496
|
codeContext.push("<document_updates>", JSON.stringify(packet.document_updates, null, 2), "Materialize every listed update. dd-flow verifies the resulting file against its PLAN-time baseline.", "</document_updates>", "", "<completion_contract>", "Successful completion requires empty deviations and blockers and every assigned document update in changed_paths. A necessary path outside planned_write_areas is normal coordination drift, not a blocker; include it in changed_paths and continue.", "</completion_contract>", "");
|
|
485
|
-
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>",
|
|
497
|
+
return ["<work>", `- work_id: ${work.work_id}`, `- run_id: ${work.run_id}`, `- project_root: ${run.project_root}`, `- workspace_root: ${run.workspace_root}`, `- run_home: ${requireRunHome(run)}`, "</work>", "", "<hard_write_boundary>", ...writeBoundary, "</hard_write_boundary>", "", ...codeContext, "<dependency_results>", JSON.stringify(dependencies.filter(Boolean), null, 2), "</dependency_results>", "", "<task>", resolveRunReferences(work.task, work.run_id, requireRunHome(run)), "</task>", "", ...(work.result_schema ? ["<result_contract>", `Return JSON matching \`${work.result_schema}\`.`, ...resultSchemaGuidance(work, run.id), "Do not create result.json yourself. Send the JSON to dd-flow on stdin; it atomically validates and stores the canonical receipt.", "</result_contract>", ""] : []), "<completion>", "Work finish runs only declared run_at=work checks. Stage finish owns readiness/code/merge gates; successful Work completion does not mean those gates have passed. A failed receipt means only that the check failed; it is not proof of an engine, harness, dependency, or environment blocker.", ...completionRepair, "Use Fail only for a concrete external blocker after deterministic bootstrap or a contradiction with an accepted requirement/non-goal. Never fail merely because a necessary project path was absent from planned_write_areas.", "Finish may run for several minutes. Preserve the shell tool's process/session handle and poll that same invocation until it exits; progress arrives as JSONL on stderr. Never reissue Finish merely because final stdout has not arrived.", `Finish as one standalone command, piping your JSON object to stdin: ${command} work finish ${work.work_id} --result-stdin --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`, `Fail only for an evidenced external or semantic-contract blocker: ${command} work fail ${work.work_id} --reason "receipt path + exact external or semantic blocker" --project-root ${JSON.stringify(run.project_root)} --json`, "</completion>", ""].join("\n");
|
|
486
498
|
}
|
|
487
|
-
function resultSchemaGuidance(work, runId) {
|
|
499
|
+
export function resultSchemaGuidance(work, runId) {
|
|
488
500
|
const schema = work.result_schema;
|
|
489
501
|
const refs = `Evidence refs for project source are relative to workspace_root; RUN evidence uses run://${runId}/path/to/artifact.`;
|
|
490
|
-
if (schema === "dd-flow/code-work-result@
|
|
491
|
-
|
|
502
|
+
if (schema === "dd-flow/code-work-result@3") {
|
|
503
|
+
const packet = codePacket(work);
|
|
504
|
+
const obligations = [...new Set([
|
|
505
|
+
...(packet?.acceptance ?? []).map((item) => item.criterion_id).filter((id) => Boolean(id)),
|
|
506
|
+
...(packet?.checks ?? []).map((item) => item.id),
|
|
507
|
+
...(packet?.repair?.review_findings ?? []).flatMap((finding) => finding.obligation_refs ?? [])
|
|
508
|
+
])];
|
|
509
|
+
const findings = packet?.repair?.review_findings ?? [];
|
|
510
|
+
return [refs, `Assigned obligation refs: ${JSON.stringify(obligations)}. Cite only these in evidence.obligation_ref.`,
|
|
511
|
+
"This is a shape template, not a completed result. Populate changed_paths and evidence from the actual work and existing files; empty arrays below do not establish completion.",
|
|
512
|
+
"For a gate repair, cite the assigned check id. For a review repair, replace each resolution summary and fill evidence_refs with existing evidence proving its required outcome. Ordinary Work has resolutions: [].",
|
|
513
|
+
"```json", JSON.stringify({ schema_id: schema, summary: "Describe the actual implementation.", changed_paths: [], evidence: [], deviations: [], blockers: [], resolutions: findings.map((finding) => ({ finding_ref: finding.finding_ref, summary: "Describe how the assigned required outcome was achieved.", evidence_refs: [] })) }, null, 2), "```",
|
|
514
|
+
"Each evidence entry has exactly obligation_ref (one assigned ref) and refs (nonempty list of existing evidence paths). Each resolution has finding_ref, summary and nonempty evidence_refs. Do not invent file names or finding ids."];
|
|
515
|
+
}
|
|
492
516
|
if (schema === "dd-flow/code-review-result@1") {
|
|
493
|
-
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "
|
|
517
|
+
return [refs, "Assess every assigned aspect exactly once. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id to form the canonical reference.", "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Report only material, direct-evidence findings; taste and cosmetics are not findings:", "```json", JSON.stringify({ schema_id: schema, verdict: "pass | findings | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | findings | blocked", summary: "Conclusion.", evidence_refs: [] }], findings: [{ finding_id: "FIND-001", aspect_id: "assigned_aspect_id", priority: "p0 | p1 | p2 | p3", problem: "Violated obligation or rule.", impact: "Concrete risk or failure.", required_outcome: "Smallest observable result that closes the defect.", evidence_refs: ["path/to/file"], obligation_refs: ["R-001 | AC-001 | policy ref"] }] }, null, 2), "```"];
|
|
494
518
|
}
|
|
495
519
|
if (schema !== "dd-flow/plan-review-result@1")
|
|
496
520
|
return [];
|
|
497
|
-
return [refs, "
|
|
521
|
+
return [refs, "This is a shape template: replace labels with assigned ids and observed verdicts, fill evidence_refs using existing files from the task packet, and remove findings when none exist. Do not add fields. Number findings locally inside this reviewer Work as FIND-001, FIND-002, ...; dd-flow adds the Work id when the coordinator classifies them:", "```json", JSON.stringify({ schema_id: schema, plan_revision: 1, overall_verdict: "pass | watch | needs_changes | blocked", summary: "Concise evidence-backed conclusion.", aspects: [{ aspect_id: "assigned_aspect_id", verdict: "pass | watch | needs_changes | blocked", summary: "Conclusion for this aspect.", evidence_refs: [], findings: [{ finding_id: "FIND-001", severity: "high | medium | low | info", summary: "Problem, if any.", evidence_refs: ["path/to/file"] }] }] }, null, 2), "```"];
|
|
498
522
|
}
|
|
499
523
|
export function validateCodeReviewResultIdentity(work, value) {
|
|
500
524
|
const group = codeReviewGroup(work);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
|
|
6
|
+
import { confirmManagedProcess, finishManagedProcess, heartbeatManagedProcess, managedOwnerIsAlive, managedProcessStatus, processTreeIsAlive, registerManagedProcess } from "./managed-processes.js";
|
|
7
|
+
/** CODE and MERGE prepare their own checkout through the same managed executor.
|
|
8
|
+
* Bootstrap is a project operation, not an alias classified by the engine as a test. */
|
|
9
|
+
export async function runWorkspaceBootstrap(context, input) {
|
|
10
|
+
const operationId = `bootstrap:${crypto.createHash("sha256").update(JSON.stringify([input.runId, input.workspaceRoot, input.artifactDir, input.command])).digest("hex")}`;
|
|
11
|
+
const pending = managedProcessStatus(context).find(item => item.operation_id === operationId && ["starting", "running", "stopping"].includes(item.state));
|
|
12
|
+
if (pending) {
|
|
13
|
+
if (processTreeIsAlive(pending) || (pending.state === "starting" && managedOwnerIsAlive(pending) !== false)) {
|
|
14
|
+
throw new AppError("bootstrap_in_progress", "Workspace preparation is still running; inspect its logs and wait for the existing process, do not launch another copy", 2, { process_id: pending.id, stdout_path: pending.stdout_path, stderr_path: pending.stderr_path });
|
|
15
|
+
}
|
|
16
|
+
const completionPath = path.join(path.dirname(pending.stdout_path), "completion.json");
|
|
17
|
+
let completion = null;
|
|
18
|
+
try {
|
|
19
|
+
completion = JSON.parse(fs.readFileSync(completionPath, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch { /* Interrupted shell has no terminal marker. */ }
|
|
22
|
+
const exitCode = typeof completion?.exit_code === "number" ? completion.exit_code : null;
|
|
23
|
+
const error = exitCode === null ? "bootstrap ended without a completion marker; inspect retained logs before retrying" : null;
|
|
24
|
+
finishManagedProcess(context, { id: pending.id, leaseToken: pending.lease_token, state: exitCode === 0 ? "stopped" : "failed", reason: error ?? "bootstrap_completion_reconciled" });
|
|
25
|
+
return result(pending.id, pending.stdout_path, pending.stderr_path, completionPath, exitCode, error);
|
|
26
|
+
}
|
|
27
|
+
const directory = path.join(input.runHome, input.artifactDir, `bootstrap-${crypto.randomUUID()}`);
|
|
28
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
29
|
+
const stdoutPath = path.join(directory, "stdout.log"), stderrPath = path.join(directory, "stderr.log"), completionPath = path.join(directory, "completion.json");
|
|
30
|
+
const managed = registerManagedProcess(context, { kind: "workspace-bootstrap", ownerId: operationId, ownerPid: process.pid, operationId, projectId: input.projectId, runId: input.runId, stdoutPath, stderrPath, metadata: { command: input.command, workspace_root: input.workspaceRoot } });
|
|
31
|
+
const stdout = fs.openSync(stdoutPath, "a"), stderr = fs.openSync(stderrPath, "a");
|
|
32
|
+
try {
|
|
33
|
+
input.progress?.(`workspace bootstrap started: ${input.command}; logs: ${directory}`);
|
|
34
|
+
const completed = await runCheck(input.command, input.workspaceRoot, { ...codeExecutionEnvironment(input.workspaceRoot), DD_FLOW_CHECK_COMPLETION_FILE: completionPath }, stdout, stderr, elapsed => input.progress?.(`workspace bootstrap still running (${elapsed}s); logs: ${directory}; next report in 15s`), pid => confirmManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), () => heartbeatManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token }));
|
|
35
|
+
const current = managedProcessStatus(context).find(item => item.id === managed.id);
|
|
36
|
+
if (processTreeIsAlive(current))
|
|
37
|
+
throw new AppError("bootstrap_tree_not_settled", "Bootstrap shell exited but still owns live processes; resources were retained for inspection", 2, { process_id: managed.id, stdout_path: stdoutPath, stderr_path: stderrPath });
|
|
38
|
+
finishManagedProcess(context, { id: managed.id, leaseToken: managed.lease_token, state: completed.exitCode === 0 && !completed.error ? "stopped" : "failed", reason: completed.error });
|
|
39
|
+
return result(managed.id, stdoutPath, stderrPath, completionPath, completed.exitCode, completed.error);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
fs.closeSync(stdout);
|
|
43
|
+
fs.closeSync(stderr);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function result(id, stdout, stderr, completion, exitCode, error) {
|
|
47
|
+
// Full logs remain on disk; keep the returned prompt bounded.
|
|
48
|
+
const excerpt = (file) => fs.existsSync(file) ? fs.readFileSync(file, "utf8").slice(-64_000) : "";
|
|
49
|
+
return { exit_code: exitCode, stdout: excerpt(stdout), stderr: excerpt(stderr), error, process_id: id, stdout_path: stdout, stderr_path: stderr, completion_path: completion };
|
|
50
|
+
}
|
package/dist/storage/database.js
CHANGED
|
@@ -574,6 +574,7 @@ function migrate(db, dbPath) {
|
|
|
574
574
|
lock_acquired_at TEXT,
|
|
575
575
|
checkpoint TEXT NOT NULL DEFAULT 'queued',
|
|
576
576
|
profile_hash TEXT,
|
|
577
|
+
replacement_of_merge_request_id TEXT,
|
|
577
578
|
adapter_receipt_json TEXT,
|
|
578
579
|
result_json TEXT,
|
|
579
580
|
last_error_json TEXT,
|
|
@@ -585,8 +586,8 @@ function migrate(db, dbPath) {
|
|
|
585
586
|
);
|
|
586
587
|
CREATE INDEX IF NOT EXISTS idx_merge_requests_fifo
|
|
587
588
|
ON merge_requests(project_id, status, created_at, merge_request_id);
|
|
588
|
-
CREATE
|
|
589
|
-
ON merge_requests(project_id, run_id);
|
|
589
|
+
CREATE INDEX IF NOT EXISTS idx_merge_requests_run
|
|
590
|
+
ON merge_requests(project_id, run_id, created_at);
|
|
590
591
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_merge_requests_active_project
|
|
591
592
|
ON merge_requests(project_id)
|
|
592
593
|
WHERE status IN ('active', 'waiting_user', 'action_required', 'recovery_required');
|
|
@@ -811,6 +812,11 @@ function migrate(db, dbPath) {
|
|
|
811
812
|
ensureColumn(db, "runs", "run_root", "ALTER TABLE runs ADD COLUMN run_root TEXT");
|
|
812
813
|
db.prepare("UPDATE runs SET run_root = COALESCE(run_root, run_home_path, run_dir) WHERE run_root IS NULL OR run_root = ''").run();
|
|
813
814
|
ensureColumn(db, "merge_requests", "accepted_tree", "ALTER TABLE merge_requests ADD COLUMN accepted_tree TEXT");
|
|
815
|
+
ensureColumn(db, "merge_requests", "replacement_of_merge_request_id", "ALTER TABLE merge_requests ADD COLUMN replacement_of_merge_request_id TEXT");
|
|
816
|
+
// A failed integration gate may create a new immutable source candidate in
|
|
817
|
+
// the same RUN. The historical unique index made that repair path impossible.
|
|
818
|
+
db.exec("DROP INDEX IF EXISTS idx_merge_requests_run");
|
|
819
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_merge_requests_run ON merge_requests(project_id, run_id, created_at)");
|
|
814
820
|
ensureColumn(db, "runs", "layout_version", "ALTER TABLE runs ADD COLUMN layout_version TEXT");
|
|
815
821
|
ensureColumn(db, "runs", "artifact_root_kind", "ALTER TABLE runs ADD COLUMN artifact_root_kind TEXT");
|
|
816
822
|
ensureColumn(db, "works", "payload_json", "ALTER TABLE works ADD COLUMN payload_json TEXT");
|