@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.105
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 +6 -0
- package/dist/build-info.json +3 -3
- package/dist/cli/run-cli.js +23 -8
- package/dist/harness-runtime/bin/dd-agy.mjs +1 -1
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +2 -2
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +4 -3
- package/dist/harness-runtime/lib/delegation-instructions.d.mts +2 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +8 -1
- package/dist/protocol/local-files.js +18 -9
- package/dist/runtime/context.js +2 -2
- package/dist/services/cleanup.js +81 -32
- package/dist/services/code-checks.js +18 -19
- package/dist/services/continuation-outcome.js +1 -1
- package/dist/services/controller-fanout.js +3 -3
- package/dist/services/eval-snapshots.js +69 -41
- package/dist/services/execution-policy.js +12 -1
- package/dist/services/hooks.js +150 -42
- package/dist/services/lanes.js +23 -18
- package/dist/services/lifecycle-command.js +11 -2
- package/dist/services/lifecycle-invocations.js +291 -54
- package/dist/services/managed-processes.js +35 -23
- package/dist/services/merge-queue.js +71 -67
- package/dist/services/merge-server.js +3 -11
- package/dist/services/migrations.js +28 -12
- package/dist/services/plan-runtime.js +8 -4
- package/dist/services/projects.js +3 -11
- package/dist/services/protocols.js +98 -68
- package/dist/services/recovery-snapshot-database.js +43 -8
- package/dist/services/run-control-worker.js +15 -32
- package/dist/services/run-control.js +3 -17
- package/dist/services/run-controller-capture.js +58 -27
- package/dist/services/run-controller-process.js +21 -30
- package/dist/services/run-controller-recovery.js +36 -37
- package/dist/services/run-controller.js +81 -86
- package/dist/services/run-fork.js +10 -6
- package/dist/services/run-projection.js +19 -64
- package/dist/services/run-recovery-runtime.js +24 -14
- package/dist/services/run-recovery.js +56 -54
- package/dist/services/runs.js +218 -160
- package/dist/services/runtime-budget.js +19 -45
- package/dist/services/runtime-scope-capture.js +8 -6
- package/dist/services/runtime-scope-control.js +49 -39
- package/dist/services/runtime-scope-resume.js +134 -122
- package/dist/services/runtime-scope-worker.js +48 -49
- package/dist/services/scope-writer-cleanup.js +26 -0
- package/dist/services/stage-context.js +6 -1
- package/dist/services/stage-lifecycle.js +1 -1
- package/dist/services/stage-pause.js +13 -29
- package/dist/services/stage-report-renderer.js +116 -6
- package/dist/services/usage.js +2 -9
- package/dist/services/vnext-code-review.js +10 -7
- package/dist/services/vnext-code.js +9 -17
- package/dist/services/vnext-merge.js +64 -21
- package/dist/services/vnext-plan-review.js +43 -30
- package/dist/services/vnext-plan.js +28 -24
- package/dist/services/vnext-protocolize.js +183 -20
- package/dist/services/vnext-specify.js +27 -19
- package/dist/services/work-registry.js +229 -150
- package/dist/storage/database.js +286 -56
- package/dist/storage/foreign-key-transaction.js +39 -0
- package/dist/storage/immutable-input.js +45 -0
- package/dist/storage/import-guard.js +102 -0
- package/dist/storage/native-cleanup.js +52 -0
- package/dist/storage/projection-lock.js +73 -0
- package/dist/storage/writer-contract.js +3 -3
- package/dist/storage/writer-migration.js +24 -41
- package/package.json +1 -1
- package/tools/repair-paused-run-status.mjs +48 -23
|
@@ -86,24 +86,23 @@ export function registerManagedProcess(context, rawInput) {
|
|
|
86
86
|
if (!input.uniqueActiveOperation)
|
|
87
87
|
return insertManagedProcess(context, input, metadataJson);
|
|
88
88
|
const db = registry(context);
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth }))
|
|
90
|
+
throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
|
|
91
|
+
return db.writeTransaction(() => {
|
|
91
92
|
const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
|
|
92
93
|
if (existing)
|
|
93
94
|
throw new AppError("managed_operation_in_progress", "The operation already owns a managed process; observe it instead of spawning a duplicate", 1, { process_id: existing.id, operation_id: input.operationId });
|
|
94
95
|
let result = insertManagedProcess(context, input, metadataJson);
|
|
95
96
|
if (observer) {
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
97
|
+
const metadata = JSON.parse(metadataJson);
|
|
98
|
+
if (input.processGroupId)
|
|
99
|
+
metadata.process_group_id = input.processGroupId;
|
|
100
|
+
const now = context.now();
|
|
101
|
+
db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
|
|
102
|
+
result = requireProcess(db, result.id);
|
|
99
103
|
}
|
|
100
|
-
db.exec("COMMIT");
|
|
101
104
|
return result;
|
|
102
|
-
}
|
|
103
|
-
catch (error) {
|
|
104
|
-
db.exec("ROLLBACK");
|
|
105
|
-
throw error;
|
|
106
|
-
}
|
|
105
|
+
});
|
|
107
106
|
}
|
|
108
107
|
function insertManagedProcess(context, input, metadataJson) {
|
|
109
108
|
const db = registry(context);
|
|
@@ -167,6 +166,21 @@ export function heartbeatManagedProcess(context, input) {
|
|
|
167
166
|
const db = registry(context);
|
|
168
167
|
return db.run("UPDATE managed_processes SET lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [leaseExpiry(context.now(), input.leaseMs), context.now(), input.id, input.leaseToken]).changes === 1;
|
|
169
168
|
}
|
|
169
|
+
/** Read-only lease check for a caller already holding a RUN writer. Renewal
|
|
170
|
+
* must happen before entering that writer, never from this assertion. */
|
|
171
|
+
export function assertManagedProcessLease(context, input) {
|
|
172
|
+
const reader = getResourceDatabase(resourceHome(context), "read_isolated");
|
|
173
|
+
try {
|
|
174
|
+
const current = reader.get("SELECT pid, lease_expires_at, state FROM managed_processes WHERE id = ? AND lease_token = ?", [input.id, input.leaseToken]);
|
|
175
|
+
if (!current || current.pid !== input.pid || !["starting", "running"].includes(current.state)
|
|
176
|
+
|| Date.parse(current.lease_expires_at) <= Date.now()) {
|
|
177
|
+
throw new AppError("controller_lease_lost", "Managed owner lease changed or expired before productive dispatch", 1, { process_id: input.id });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
reader.close?.();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
170
184
|
export function prepareManagedProcessFinish(context, input) {
|
|
171
185
|
requireProcessText(input.id, "id");
|
|
172
186
|
requireProcessText(input.leaseToken, "lease-token");
|
|
@@ -234,26 +248,24 @@ export function managedOwnerIsAlive(record) {
|
|
|
234
248
|
export function claimExpiredManagedProcesses(context, ownerId) {
|
|
235
249
|
const db = registry(context);
|
|
236
250
|
const now = context.now();
|
|
237
|
-
db.
|
|
238
|
-
|
|
239
|
-
|
|
251
|
+
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]).filter(candidate => {
|
|
252
|
+
const ownerAlive = managedOwnerIsAlive(candidate);
|
|
253
|
+
return ownerAlive !== true && !(ownerAlive === null && processTreeIsAlive(candidate));
|
|
254
|
+
});
|
|
255
|
+
return db.writeTransaction(() => {
|
|
240
256
|
const claimed = [];
|
|
241
257
|
for (const candidate of candidates) {
|
|
242
|
-
|
|
243
|
-
|
|
258
|
+
// A heartbeat or replacement registration invalidates the pre-writer
|
|
259
|
+
// death proof; expiry alone never authorizes claiming that new owner.
|
|
260
|
+
if (JSON.stringify(db.get("SELECT * FROM managed_processes WHERE id = ?", [candidate.id])) !== JSON.stringify(candidate))
|
|
244
261
|
continue;
|
|
245
262
|
const token = crypto.randomUUID();
|
|
246
263
|
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]);
|
|
247
264
|
if (result.changes === 1)
|
|
248
265
|
claimed.push({ ...candidate, owner_id: ownerId, lease_token: token, state: "orphaned", updated_at: now });
|
|
249
266
|
}
|
|
250
|
-
db.exec("COMMIT");
|
|
251
267
|
return claimed;
|
|
252
|
-
}
|
|
253
|
-
catch (error) {
|
|
254
|
-
db.exec("ROLLBACK");
|
|
255
|
-
throw error;
|
|
256
|
-
}
|
|
268
|
+
});
|
|
257
269
|
}
|
|
258
270
|
/** Workspace gates are leases, not spawned processes. Children retain their own registrations. */
|
|
259
271
|
export function managedLeaseReleased(record) {
|
|
@@ -400,7 +412,7 @@ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
|
|
|
400
412
|
throw new AppError("validation", "--lease-ms must be a positive integer within the supported date range", 2);
|
|
401
413
|
return new Date(Date.parse(now) + leaseMs).toISOString();
|
|
402
414
|
}
|
|
403
|
-
export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
|
|
415
|
+
export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
|
|
404
416
|
function releasePortClaims(db, claims) { for (const claim of claims)
|
|
405
417
|
db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
|
|
406
418
|
function terminateOwnedProcess(record, signal) {
|
|
@@ -8,6 +8,38 @@ import { ensureLaneWorkspace, heartbeatLaneLock, laneRecord, prepareLaneLock, pr
|
|
|
8
8
|
import { stopMergeWorker, stoppedMergeWorkerState } from "./sessions.js";
|
|
9
9
|
import { getProtocolBranchContext, requireClaimableBranchBundle } from "./branch-context.js";
|
|
10
10
|
const mergeLockTtlSeconds = 300;
|
|
11
|
+
/** SQL evidence comes from the same read snapshot as filesystem preparation.
|
|
12
|
+
* Bundle membership scans only branch metadata; unrelated RUN writes do not
|
|
13
|
+
* invalidate an individual job's accepted inputs. */
|
|
14
|
+
function prepareMergePublication(context, root, workerId, prepare, protocolIds, mode, requireLock = true) {
|
|
15
|
+
const project = requireProjectByRoot(context, resolveProjectRoot(root));
|
|
16
|
+
return context.db.readSnapshot(() => {
|
|
17
|
+
const value = prepare(), ids = JSON.stringify(protocolIds(value));
|
|
18
|
+
const observe = () => ({
|
|
19
|
+
root: context.db.get("SELECT root FROM projects WHERE id = ?", [project.id]),
|
|
20
|
+
lane: context.db.get("SELECT workspace_path, expected_branch, status FROM lanes WHERE project_id = ? AND name = 'merge'", [project.id]),
|
|
21
|
+
lock: context.db.get("SELECT id, worker_id, lease_token FROM lane_locks WHERE project_id = ? AND lane = 'merge' AND status = 'active'", [project.id]),
|
|
22
|
+
worker: workerId ? stoppedMergeWorkerState(context, project.id, workerId) : null,
|
|
23
|
+
queue: mode === "head" ? context.db.all("SELECT * FROM merge_queue WHERE project_id = ? AND status IN ('ready','requeued') ORDER BY created_at, id LIMIT 1", [project.id])
|
|
24
|
+
: context.db.all(`SELECT * FROM merge_queue WHERE project_id = ? ${mode === "bundle" ? "" : "AND protocol_id IN (SELECT value FROM json_each(?))"} ORDER BY id`, mode === "bundle" ? [project.id] : [project.id, ids]),
|
|
25
|
+
protocols: context.db.all("SELECT * FROM protocols WHERE project_id = ? AND id IN (SELECT value FROM json_each(?)) ORDER BY id", [project.id, ids]),
|
|
26
|
+
states: context.db.all("SELECT protocol_id, payload_json FROM audit_events WHERE id IN (SELECT MAX(id) FROM audit_events WHERE project_id = ? AND event_type = 'protocol.state_projection' AND protocol_id IN (SELECT value FROM json_each(?)) GROUP BY protocol_id) ORDER BY protocol_id", [project.id, ids]),
|
|
27
|
+
runs: context.db.all("SELECT id, subject_id, status, verdict, next_action, index_json, created_at, updated_at, completed_at FROM runs WHERE project_id = ? AND subject_type = 'protocol' AND subject_id IN (SELECT value FROM json_each(?)) ORDER BY id", [project.id, ids]),
|
|
28
|
+
...(mode === "bundle" ? {
|
|
29
|
+
membership: context.db.all("SELECT id, status, stage, next_action, workspace_json, route_json, active_def_json FROM protocols WHERE project_id = ? ORDER BY id", [project.id]),
|
|
30
|
+
worktrees: context.db.all("SELECT protocol_id, integration_branch, feature_branch, base_ref, worktree_path, status FROM worktree_records WHERE project_id = ? ORDER BY protocol_id", [project.id]),
|
|
31
|
+
sessions: context.db.all("SELECT session_id, protocol_id, worker_id, flow_kind, status, current_stage, next_action FROM sessions WHERE project_id = ? AND status IN ('pending','active','waiting_user','blocked','stopping') ORDER BY session_id", [project.id])
|
|
32
|
+
} : {})
|
|
33
|
+
});
|
|
34
|
+
const expected = JSON.stringify(observe());
|
|
35
|
+
return { value, assertCurrent: () => {
|
|
36
|
+
if (JSON.stringify(observe()) !== expected)
|
|
37
|
+
throw new AppError("merge_preparation_changed", "Merge ownership or input authority changed during preparation", 1, { effect: "no_effect" });
|
|
38
|
+
if (requireLock && !context.db.get("SELECT 1 FROM lane_locks WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND worker_id = ? AND expires_at > ?", [project.id, workerId, context.now()]))
|
|
39
|
+
throw new AppError("lane_lock_not_active", "Prepared merge lost its live lane lease", 1, { effect: "no_effect" });
|
|
40
|
+
} };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
11
43
|
export function getMergeQueueStatus(context, input) {
|
|
12
44
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
13
45
|
return {
|
|
@@ -47,21 +79,19 @@ export function prepareMergeClaim(context, input) {
|
|
|
47
79
|
return { project, job, transition };
|
|
48
80
|
}
|
|
49
81
|
export function claimNextMergeJob(context, input) {
|
|
50
|
-
const
|
|
82
|
+
const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeClaim(context, input), value => value.job ? [value.job.protocol_id] : [], "head");
|
|
83
|
+
const { project, job, transition } = prepared.value;
|
|
51
84
|
const now = context.now();
|
|
52
85
|
let claimedProtocolId = null;
|
|
53
|
-
context.db.
|
|
54
|
-
|
|
55
|
-
const { job, transition } = prepareMergeClaim(context, input);
|
|
86
|
+
const early = context.db.writeTransaction(() => {
|
|
87
|
+
prepared.assertCurrent();
|
|
56
88
|
if (!job) {
|
|
57
|
-
context.db.exec("COMMIT");
|
|
58
89
|
return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
|
|
59
90
|
}
|
|
60
91
|
const update = context.db.run(`UPDATE merge_queue
|
|
61
92
|
SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
|
|
62
93
|
WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
|
|
63
94
|
if (update.changes !== 1) {
|
|
64
|
-
context.db.exec("COMMIT");
|
|
65
95
|
return mergeQueueResult(null, { ok: true, outcome: "empty", claimed: false });
|
|
66
96
|
}
|
|
67
97
|
appendAudit(context, {
|
|
@@ -72,12 +102,10 @@ export function claimNextMergeJob(context, input) {
|
|
|
72
102
|
});
|
|
73
103
|
transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
|
|
74
104
|
claimedProtocolId = job.protocol_id;
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
throw error;
|
|
80
|
-
}
|
|
105
|
+
return null;
|
|
106
|
+
});
|
|
107
|
+
if (early)
|
|
108
|
+
return early;
|
|
81
109
|
return mergeQueueResult(claimedProtocolId ? queueJobByProtocol(context, project.id, claimedProtocolId) : null, {
|
|
82
110
|
ok: true,
|
|
83
111
|
outcome: claimedProtocolId ? "claimed" : "empty",
|
|
@@ -86,13 +114,12 @@ export function claimNextMergeJob(context, input) {
|
|
|
86
114
|
}
|
|
87
115
|
export function claimMergeJob(context, input) {
|
|
88
116
|
requireMergeText(input.protocolId, "protocol-id");
|
|
89
|
-
const
|
|
117
|
+
const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeClaim(context, input), () => [input.protocolId], "target");
|
|
118
|
+
const { project, job: selectedJob, transition: selectedTransition } = prepared.value;
|
|
90
119
|
const now = context.now();
|
|
91
|
-
context.db.
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
const job = prepared.job;
|
|
95
|
-
const transition = prepared.transition;
|
|
120
|
+
return context.db.writeTransaction(() => {
|
|
121
|
+
prepared.assertCurrent();
|
|
122
|
+
const job = selectedJob, transition = selectedTransition;
|
|
96
123
|
const update = context.db.run(`UPDATE merge_queue
|
|
97
124
|
SET status = 'claimed', claimed_by_session_id = ?, claimed_at = ?, updated_at = ?
|
|
98
125
|
WHERE id = ? AND status IN ('ready', 'requeued')`, [input.workerId, now, now, job.id]);
|
|
@@ -106,13 +133,8 @@ export function claimMergeJob(context, input) {
|
|
|
106
133
|
payload: { protocol_id: job.protocol_id, worker_id: input.workerId, targeted: true }
|
|
107
134
|
});
|
|
108
135
|
transitionClaimedProtocolToIntegration(context, transition, input.workerId, now);
|
|
109
|
-
context.db.exec("COMMIT");
|
|
110
136
|
return mergeQueueResult(queueJobByProtocol(context, project.id, job.protocol_id), { ok: true, outcome: "claimed", claimed: true, targeted: true });
|
|
111
|
-
}
|
|
112
|
-
catch (error) {
|
|
113
|
-
context.db.exec("ROLLBACK");
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
137
|
+
});
|
|
116
138
|
}
|
|
117
139
|
export function prepareMergeBundleClaim(context, input) {
|
|
118
140
|
requireMergeText(input.workerId, "worker-id");
|
|
@@ -138,11 +160,11 @@ export function prepareMergeBundleClaim(context, input) {
|
|
|
138
160
|
return { project, protocolIds, transitions };
|
|
139
161
|
}
|
|
140
162
|
export function claimMergeBundle(context, input) {
|
|
141
|
-
const
|
|
163
|
+
const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleClaim(context, input), value => value.protocolIds, "bundle");
|
|
164
|
+
const { project, protocolIds, transitions } = prepared.value;
|
|
142
165
|
const now = context.now();
|
|
143
|
-
context.db.
|
|
144
|
-
|
|
145
|
-
const transitions = protocolIds.map((protocolId) => prepareClaimedProtocolTransition(context, project.id, protocolId));
|
|
166
|
+
context.db.writeTransaction(() => {
|
|
167
|
+
prepared.assertCurrent();
|
|
146
168
|
// Claim every queue row before publishing any filesystem state.
|
|
147
169
|
for (const protocolId of protocolIds) {
|
|
148
170
|
const update = context.db.run(`UPDATE merge_queue
|
|
@@ -170,12 +192,7 @@ export function claimMergeBundle(context, input) {
|
|
|
170
192
|
eventType: "merge_bundle.claimed",
|
|
171
193
|
payload: { project_id: project.id, worker_id: input.workerId, protocol_ids: protocolIds, workspace_path: input.workspacePath }
|
|
172
194
|
});
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
catch (error) {
|
|
176
|
-
context.db.exec("ROLLBACK");
|
|
177
|
-
throw error;
|
|
178
|
-
}
|
|
195
|
+
});
|
|
179
196
|
return mergeBundleResult(context, {
|
|
180
197
|
ok: true,
|
|
181
198
|
outcome: "claimed",
|
|
@@ -412,12 +429,11 @@ export function prepareMergeBundleCompletion(context, input) {
|
|
|
412
429
|
return { project, protocolIds, jobs };
|
|
413
430
|
}
|
|
414
431
|
export function completeMergeBundle(context, input) {
|
|
415
|
-
prepareMergeBundleCompletion(context, input);
|
|
432
|
+
const publication = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleCompletion(context, input), value => value.protocolIds, "bundle");
|
|
433
|
+
const prepared = publication.value;
|
|
416
434
|
const now = context.now();
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
try {
|
|
420
|
-
prepared = prepareMergeBundleCompletion(context, input);
|
|
435
|
+
context.db.writeTransaction(() => {
|
|
436
|
+
publication.assertCurrent();
|
|
421
437
|
const { project, protocolIds, jobs } = prepared;
|
|
422
438
|
for (const { protocol, job, state, flowContract, targetStage, targetStatus } of jobs) {
|
|
423
439
|
const protocolId = protocol.id;
|
|
@@ -443,12 +459,7 @@ export function completeMergeBundle(context, input) {
|
|
|
443
459
|
eventType: "merge_bundle.completed",
|
|
444
460
|
payload: { project_id: project.id, worker_id: input.workerId, summary: input.summary, protocol_ids: protocolIds }
|
|
445
461
|
});
|
|
446
|
-
|
|
447
|
-
}
|
|
448
|
-
catch (error) {
|
|
449
|
-
context.db.exec("ROLLBACK");
|
|
450
|
-
throw error;
|
|
451
|
-
}
|
|
462
|
+
});
|
|
452
463
|
const { project, protocolIds } = prepared;
|
|
453
464
|
const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle complete after stop request");
|
|
454
465
|
return mergeBundleResult(context, {
|
|
@@ -569,13 +580,12 @@ export function prepareMergeBundleFailure(context, input) {
|
|
|
569
580
|
return { project, protocolIds, jobs };
|
|
570
581
|
}
|
|
571
582
|
export function failMergeBundle(context, input) {
|
|
572
|
-
prepareMergeBundleFailure(context, input);
|
|
583
|
+
const publication = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeBundleFailure(context, input), value => value.protocolIds, "bundle");
|
|
584
|
+
const prepared = publication.value;
|
|
573
585
|
const now = context.now();
|
|
574
586
|
const status = input.requeue ? "requeued" : "failed";
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
try {
|
|
578
|
-
prepared = prepareMergeBundleFailure(context, input);
|
|
587
|
+
context.db.writeTransaction(() => {
|
|
588
|
+
publication.assertCurrent();
|
|
579
589
|
const { project, protocolIds, jobs } = prepared;
|
|
580
590
|
for (const { protocol, job, state } of jobs) {
|
|
581
591
|
const protocolId = protocol.id;
|
|
@@ -605,12 +615,7 @@ export function failMergeBundle(context, input) {
|
|
|
605
615
|
reason: input.reason,
|
|
606
616
|
payload: { project_id: project.id, worker_id: input.workerId, requeue: input.requeue, protocol_ids: protocolIds }
|
|
607
617
|
});
|
|
608
|
-
|
|
609
|
-
}
|
|
610
|
-
catch (error) {
|
|
611
|
-
context.db.exec("ROLLBACK");
|
|
612
|
-
throw error;
|
|
613
|
-
}
|
|
618
|
+
});
|
|
614
619
|
const { project, protocolIds } = prepared;
|
|
615
620
|
const stop_after_current = stopAfterCurrentIfRequested(context, project.id, project.root, input.workerId, "merge bundle fail after stop request");
|
|
616
621
|
return mergeBundleResult(context, {
|
|
@@ -665,13 +670,14 @@ export function prepareMergeQueueCancellation(context, input) {
|
|
|
665
670
|
return { protocol, job, reason };
|
|
666
671
|
}
|
|
667
672
|
export function cancelMergeQueueJob(context, input) {
|
|
668
|
-
const
|
|
673
|
+
const prepared = prepareMergePublication(context, input.projectRoot, input.workerId, () => prepareMergeQueueCancellation(context, input), value => [value.protocol.id], "target", false);
|
|
674
|
+
const { protocol, job, reason } = prepared.value;
|
|
669
675
|
const now = context.now();
|
|
670
|
-
context.db.
|
|
671
|
-
|
|
672
|
-
|
|
676
|
+
const early = context.db.writeTransaction(() => {
|
|
677
|
+
prepared.assertCurrent();
|
|
678
|
+
if (job.status === "claimed" && !input.force && !context.db.get("SELECT 1 FROM lane_locks WHERE project_id = ? AND lane = 'merge' AND status = 'active' AND worker_id = ? AND expires_at > ?", [protocol.project_id, input.workerId, context.now()]))
|
|
679
|
+
throw new AppError("lane_lock_not_active", "Prepared cancellation lost its live lane lease", 1);
|
|
673
680
|
if (job.status === "cancelled") {
|
|
674
|
-
context.db.exec("COMMIT");
|
|
675
681
|
return mergeQueueResult(job, { ok: true, outcome: "cancelled", cancelled: false, reason: "already_cancelled" });
|
|
676
682
|
}
|
|
677
683
|
context.db.run(`UPDATE merge_queue
|
|
@@ -685,12 +691,10 @@ export function cancelMergeQueueJob(context, input) {
|
|
|
685
691
|
forced: input.force,
|
|
686
692
|
payload: { protocol_id: protocol.id, from: job.status, worker_id: input.workerId ?? null }
|
|
687
693
|
});
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
throw error;
|
|
693
|
-
}
|
|
694
|
+
return null;
|
|
695
|
+
});
|
|
696
|
+
if (early)
|
|
697
|
+
return early;
|
|
694
698
|
return mergeQueueResult(queueJobByProtocol(context, protocol.project_id, protocol.id), { ok: true, outcome: "cancelled", cancelled: true });
|
|
695
699
|
}
|
|
696
700
|
export function queueForProject(context, projectId) {
|
|
@@ -63,26 +63,18 @@ export function stopMergeServer(context, input) { const changed = context.db.run
|
|
|
63
63
|
throw new AppError("not_found", "Running merge-server was not found", 1, { server_id: input.serverId }); return { ok: true, server_id: input.serverId, status: "stopping" }; }
|
|
64
64
|
/** Both dispatchers enter the same project FIFO before creating any provider Session. */
|
|
65
65
|
export function claimQueuedMergeRequest(context, input) {
|
|
66
|
-
context.db.
|
|
67
|
-
try {
|
|
66
|
+
return context.db.writeTransaction(() => {
|
|
68
67
|
const request = context.db.get("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE merge_request_id = ?", [input.requestId]);
|
|
69
68
|
if (!request)
|
|
70
69
|
throw new AppError("not_found", "MERGE request was not found", 1);
|
|
71
70
|
const generation = assertRunMutationAllowed(context, request.project_id, request.run_id, input.expectedGeneration);
|
|
72
71
|
const earlier = 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]);
|
|
73
|
-
if (request.status !== "queued" || request.execution_route !== "server" || earlier)
|
|
74
|
-
context.db.exec("COMMIT");
|
|
72
|
+
if (request.status !== "queued" || request.execution_route !== "server" || earlier)
|
|
75
73
|
return null;
|
|
76
|
-
}
|
|
77
74
|
const token = crypto.randomUUID();
|
|
78
75
|
context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued'", [input.ownerId, token, new Date(Date.now() + 120_000).toISOString(), context.now(), request.merge_request_id]);
|
|
79
|
-
context.db.exec("COMMIT");
|
|
80
76
|
return { request, token, generation };
|
|
81
|
-
}
|
|
82
|
-
catch (error) {
|
|
83
|
-
context.db.exec("ROLLBACK");
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
77
|
+
});
|
|
86
78
|
}
|
|
87
79
|
async function dispatch(context, input) {
|
|
88
80
|
const run = context.db.get("SELECT index_json, project_root FROM runs WHERE project_id = ? AND id = ?", [input.request.project_id, input.request.run_id]);
|
|
@@ -6,6 +6,7 @@ import { requireProjectByRoot } from "./projects.js";
|
|
|
6
6
|
import { getProjectVersionStatus, resolveStatusProjectRoot } from "./version-status.js";
|
|
7
7
|
import { resolveCanonRoot } from "./canon.js";
|
|
8
8
|
import { getDatabase } from "../storage/database.js";
|
|
9
|
+
import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
|
|
9
10
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
10
11
|
import { classifyCliOperation } from "./cli-operation-classifier.js";
|
|
11
12
|
import { compatibilityContexts, selectEngine } from "./engines.js";
|
|
@@ -204,8 +205,27 @@ export function prepareMigrationApply(context, input) {
|
|
|
204
205
|
return { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain };
|
|
205
206
|
}
|
|
206
207
|
export function applyMigration(context, input, prepared = prepareMigrationApply(context, input)) {
|
|
207
|
-
const { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain } = prepared;
|
|
208
208
|
const writeDb = getDatabase(context.ddFlowHome, "migrate");
|
|
209
|
+
let result, primary, failed = false;
|
|
210
|
+
try {
|
|
211
|
+
result = applyPreparedMigration(context, prepared, writeDb);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
primary = error;
|
|
215
|
+
failed = true;
|
|
216
|
+
}
|
|
217
|
+
const cleanup = cleanupScopeWriters([{ name: writeDb.path, db: writeDb, rollback: false, close: "always" }]);
|
|
218
|
+
if (failed) {
|
|
219
|
+
if (primary instanceof AppError && cleanup.length)
|
|
220
|
+
primary.details.migration_cleanup_errors = cleanup;
|
|
221
|
+
throw primary;
|
|
222
|
+
}
|
|
223
|
+
if (cleanup.length)
|
|
224
|
+
throw new AppError("migration_cleanup_failed", "Migration receipt is available but its connection could not be closed", 1, { effect: result?.changed ? "committed" : "no_effect", migration_cleanup_errors: cleanup });
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
function applyPreparedMigration(context, prepared, writeDb) {
|
|
228
|
+
const { project, sourceVersion, targetVersion, verifiedBackup, reportEngine, chain } = prepared;
|
|
209
229
|
const already = writeDb.get("SELECT value_json FROM runtime_config WHERE key = ?", [applyKey(project.id, sourceVersion, targetVersion)]);
|
|
210
230
|
if (already) {
|
|
211
231
|
return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "verified_noop", changed: false, backup: verifiedBackup, recovery: JSON.parse(already.value_json) };
|
|
@@ -225,18 +245,14 @@ export function applyMigration(context, input, prepared = prepareMigrationApply(
|
|
|
225
245
|
chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
|
|
226
246
|
};
|
|
227
247
|
try {
|
|
228
|
-
writeDb.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
248
|
+
writeDb.writeTransaction(() => {
|
|
249
|
+
const current = activeStateSummary({ ...context, db: writeDb }, project.id);
|
|
250
|
+
if (activeStateBlockers(current).length)
|
|
251
|
+
throw new AppError("migration_active_state", "Migration apply requires a quiescent shared home", 1, { active_state: current });
|
|
252
|
+
writeDb.run(`INSERT INTO runtime_config (key, value_json, created_at, updated_at) VALUES (?, ?, ?, ?)`, [applyKey(project.id, sourceVersion, targetVersion), JSON.stringify(result), context.now(), context.now()]);
|
|
253
|
+
});
|
|
234
254
|
}
|
|
235
255
|
catch (error) {
|
|
236
|
-
try {
|
|
237
|
-
writeDb.exec("ROLLBACK");
|
|
238
|
-
}
|
|
239
|
-
catch { /* preserve the original failure */ }
|
|
240
256
|
throw new AppError("migration_apply_failed", "Migration apply failed before upgrade RUN creation or canon sync", 1, {
|
|
241
257
|
recovery: {
|
|
242
258
|
status: "required",
|
|
@@ -246,7 +262,7 @@ export function applyMigration(context, input, prepared = prepareMigrationApply(
|
|
|
246
262
|
rollback_route: "restore the verified backup before retrying mb-upgrade apply",
|
|
247
263
|
next_action: "inspect the failed apply and retry only with the same pinned target contract"
|
|
248
264
|
},
|
|
249
|
-
cause: String(error)
|
|
265
|
+
cause: error instanceof AppError ? { code: error.code, message: error.message, details: error.details } : String(error)
|
|
250
266
|
});
|
|
251
267
|
}
|
|
252
268
|
return { ok: true, schema_id: "dd-flow/mb-upgrade-migration-apply@1", status: "applied", changed: true, backup: verifiedBackup, recovery: result };
|
|
@@ -23,7 +23,7 @@ export function readCanonicalPlan(planPath, protocolId) {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
export function prepareCanonicalPlan(context, input, canonical = readCanonicalPlan(input.planPath, input.protocolId)) {
|
|
26
|
-
const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
|
|
26
|
+
const binding = context.db.get("SELECT plan_id, plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
|
|
27
27
|
if (binding && (binding.plan_revision !== canonical.revision || binding.plan_sha256 !== canonical.sha256)) {
|
|
28
28
|
throw new AppError("plan_stale", "Canonical plan changed after the runtime binding was created", 1, {
|
|
29
29
|
protocol_id: input.protocolId,
|
|
@@ -38,11 +38,15 @@ export function prepareCanonicalPlan(context, input, canonical = readCanonicalPl
|
|
|
38
38
|
}
|
|
39
39
|
export function boundCanonicalPlan(context, input, prepared) {
|
|
40
40
|
const canonical = prepareCanonicalPlan(context, input, prepared);
|
|
41
|
-
const binding = context.db.get("SELECT plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
|
|
41
|
+
const binding = context.db.get("SELECT plan_id, plan_revision, plan_sha256, plan_path FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [input.projectId, input.protocolId]);
|
|
42
42
|
const now = context.now();
|
|
43
43
|
if (!binding) {
|
|
44
|
-
context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)
|
|
45
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`, [input.projectId, input.protocolId, canonical.revision, canonical.sha256, input.planPath, now, now]);
|
|
44
|
+
context.db.run(`INSERT INTO plan_bindings (project_id, protocol_id, plan_id, plan_revision, plan_sha256, plan_path, bound_at, updated_at)
|
|
45
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [input.projectId, input.protocolId, canonical.plan.plan_id, canonical.revision, canonical.sha256, input.planPath, now, now]);
|
|
46
|
+
}
|
|
47
|
+
else if (binding.plan_id === null) {
|
|
48
|
+
// Older bindings are upgraded when their canonical source is validated.
|
|
49
|
+
context.db.run("UPDATE plan_bindings SET plan_id = ?, updated_at = ? WHERE project_id = ? AND protocol_id = ? AND plan_id IS NULL", [canonical.plan.plan_id, now, input.projectId, input.protocolId]);
|
|
46
50
|
}
|
|
47
51
|
for (const item of canonical.plan.items) {
|
|
48
52
|
context.db.run(`INSERT INTO plan_progress
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { foreignKeyTransaction } from "../storage/foreign-key-transaction.js";
|
|
3
4
|
import { formatFullId, isFullEntityId, isShortEntityId, parseFullEntityId, slugFromRoot } from "../domain/entity-ids.js";
|
|
4
5
|
import { AppError } from "../shared/errors.js";
|
|
5
6
|
import { projectRuntimeRoot, resolveProjectRoot } from "../storage/paths.js";
|
|
@@ -148,9 +149,7 @@ export function migrateProjectIds(context, input) {
|
|
|
148
149
|
if (conflictingProject) {
|
|
149
150
|
throw new AppError("conflict", `Target project id already exists: ${target.id}`, 1, plan);
|
|
150
151
|
}
|
|
151
|
-
context.db
|
|
152
|
-
context.db.beginWriteTransaction();
|
|
153
|
-
try {
|
|
152
|
+
foreignKeyTransaction(context.db, () => {
|
|
154
153
|
context.db.run("UPDATE projects SET id = ?, short_id = ?, slug = ?, state_root = ?, updated_at = ? WHERE id = ?", [target.id, target.short_id, target.slug, target.state_root, now, oldId]);
|
|
155
154
|
for (const table of projectReferenceTables()) {
|
|
156
155
|
context.db.run(`UPDATE ${table} SET project_id = ? WHERE project_id = ?`, [target.id, oldId]);
|
|
@@ -162,14 +161,7 @@ export function migrateProjectIds(context, input) {
|
|
|
162
161
|
foreign_key_errors: foreignKeyErrors
|
|
163
162
|
});
|
|
164
163
|
}
|
|
165
|
-
|
|
166
|
-
context.db.exec("PRAGMA foreign_keys = ON");
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
context.db.exec("ROLLBACK");
|
|
170
|
-
context.db.exec("PRAGMA foreign_keys = ON");
|
|
171
|
-
throw error;
|
|
172
|
-
}
|
|
164
|
+
});
|
|
173
165
|
appendAudit(context, {
|
|
174
166
|
projectId: target.id,
|
|
175
167
|
eventType: "project.id_migrated",
|