@deksden-com/dd-flow-cli 0.9.0-beta.104 → 0.9.0-beta.106
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 +12 -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 +3 -0
- package/dist/harness-runtime/lib/delegation-instructions.mjs +10 -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 +12 -4
- package/dist/services/eval-snapshots.js +69 -41
- package/dist/services/execution-policy.js +12 -1
- package/dist/services/external-work-launch.js +2 -1
- package/dist/services/hooks.js +153 -43
- 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 +5 -12
- 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 +83 -88
- package/dist/services/run-fork.js +10 -6
- package/dist/services/run-projection.js +22 -64
- package/dist/services/run-recovery-runtime.js +25 -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
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { quote } from "shell-quote";
|
|
8
8
|
import { ensureRunControllerStorage, getDatabase } from "../storage/database.js";
|
|
9
9
|
import { canonicalPath, resolveProjectRoot } from "../storage/paths.js";
|
|
10
|
+
import { publishImmutableInput } from "../storage/immutable-input.js";
|
|
10
11
|
import { AppError } from "../shared/errors.js";
|
|
11
12
|
import { prepareFileBytes, prepareJsonText } from "../cli/input-preparation.js";
|
|
12
13
|
import { parseRuntimeBudget } from "./runtime-budget.js";
|
|
@@ -15,14 +16,14 @@ import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
|
|
|
15
16
|
import { getFlowRunStatus, setFlowRunRecoveryPaused } from "./runs.js";
|
|
16
17
|
import { loadExternalStageContext } from "./stage-context.js";
|
|
17
18
|
import { flowCommand } from "./stage-pause.js";
|
|
18
|
-
import { resolveStageTransition, sameSessionProfile, runtimeHarness } from "./execution-policy.js";
|
|
19
|
+
import { resolveStageTransition, sameSessionProfile, stageExecutionCwd, runtimeHarness } from "./execution-policy.js";
|
|
19
20
|
import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
|
|
20
21
|
import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
|
|
21
|
-
import { confirmManagedProcess, heartbeatManagedProcess, finishManagedProcess, processTreeIsAlive, resourceHome } from "./managed-processes.js";
|
|
22
|
+
import { assertManagedProcessLease, confirmManagedProcess, heartbeatManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
|
|
22
23
|
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
23
24
|
import { initCodexHome } from "./hooks.js";
|
|
24
25
|
import { launchExternalWork } from "./external-work-launch.js";
|
|
25
|
-
import { controllerNativeChildren, controllerStageEntryPrompt, nextControllerFanout } from "./controller-fanout.js";
|
|
26
|
+
import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
|
|
26
27
|
import { claimQueuedMergeRequest } from "./merge-server.js";
|
|
27
28
|
import { prepareVnextCodeWorkspace } from "./vnext-code.js";
|
|
28
29
|
import { requireRun, requireController, controllerReceipt, appendEvent, saveState, retainControllerContinuation, digest, digestBytes, errorRecord } from "./run-controller-state.js";
|
|
@@ -98,13 +99,12 @@ export async function launchRunController(context, input, prepared = prepareRunC
|
|
|
98
99
|
const token = crypto.randomUUID();
|
|
99
100
|
const now = context.now();
|
|
100
101
|
const state = { stage: null, sessions: [], current_session: null, turns: 0, last_continuation: null };
|
|
101
|
-
|
|
102
|
-
|
|
102
|
+
const launcher = { pid: process.pid, pid_started_at: processStartedAt(process.pid) };
|
|
103
|
+
const duplicate = context.db.writeTransaction(() => {
|
|
103
104
|
const duplicate = context.db.get("SELECT * FROM run_controllers WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, run.id, input.requestId]);
|
|
104
105
|
if (duplicate) {
|
|
105
106
|
if (duplicate.request_hash !== requestHash)
|
|
106
107
|
throw new AppError("runtime_request_conflict", "Managed launch request id has different inputs", 1);
|
|
107
|
-
context.db.exec("COMMIT");
|
|
108
108
|
return { ...controllerReceipt(duplicate), reused: true };
|
|
109
109
|
}
|
|
110
110
|
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
@@ -116,14 +116,12 @@ export async function launchRunController(context, input, prepared = prepareRunC
|
|
|
116
116
|
if (context.db.get("SELECT 1 FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id WHERE w.project_id = ? AND w.run_id = ? AND ws.status = 'running' LIMIT 1", [project.id, run.id]))
|
|
117
117
|
throw new AppError("controller_attach_required", "A WorkSession acquired the RUN before managed launch", 1);
|
|
118
118
|
context.db.run("INSERT INTO run_controllers (controller_id, project_id, run_id, request_id, request_hash, generation, status, owner_token, manifest_json, state_json, state_dir, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?)", [controllerId, project.id, run.id, input.requestId, requestHash, generation, token, JSON.stringify(manifest), JSON.stringify(state), root, now, now]);
|
|
119
|
-
recordControllerLauncher(context, requireController(context, controllerId));
|
|
119
|
+
recordControllerLauncher(context, requireController(context, controllerId), undefined, launcher);
|
|
120
120
|
appendEvent(context, controllerId, "launch_requested", { request_id: input.requestId, generation, request_hash: requestHash });
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
121
|
+
return null;
|
|
122
|
+
});
|
|
123
|
+
if (duplicate)
|
|
124
|
+
return duplicate;
|
|
127
125
|
return await spawnController(context, requireController(context, controllerId));
|
|
128
126
|
}
|
|
129
127
|
/**
|
|
@@ -136,24 +134,34 @@ export async function launchRunController(context, input, prepared = prepareRunC
|
|
|
136
134
|
export function controllerLeaseMonitor(heartbeat) {
|
|
137
135
|
let lost = false;
|
|
138
136
|
let unconfirmed = null;
|
|
137
|
+
let confirmedUntil = 0;
|
|
139
138
|
const lostError = () => new AppError("controller_lease_lost", "Managed owner lease could not be confirmed", 1);
|
|
139
|
+
const uncertainError = () => new AppError("controller_lease_unconfirmed", "Managed owner lease requires renewal outside the RUN writer", 1);
|
|
140
140
|
return {
|
|
141
141
|
tick() {
|
|
142
142
|
try {
|
|
143
143
|
if (!heartbeat())
|
|
144
144
|
lost = true;
|
|
145
|
-
else
|
|
145
|
+
else {
|
|
146
146
|
unconfirmed = null;
|
|
147
|
+
confirmedUntil = Date.now() + 14 * 60_000;
|
|
148
|
+
}
|
|
147
149
|
}
|
|
148
150
|
catch (error) {
|
|
149
151
|
unconfirmed = error;
|
|
150
152
|
}
|
|
151
153
|
},
|
|
152
|
-
isConfirmed() { return !lost && unconfirmed === null; },
|
|
154
|
+
isConfirmed() { return !lost && unconfirmed === null && Date.now() < confirmedUntil; },
|
|
155
|
+
assertNoRenewal() {
|
|
156
|
+
if (lost)
|
|
157
|
+
throw lostError();
|
|
158
|
+
if (unconfirmed !== null || Date.now() >= confirmedUntil)
|
|
159
|
+
throw uncertainError();
|
|
160
|
+
},
|
|
153
161
|
assertConfirmed() {
|
|
154
162
|
if (lost)
|
|
155
163
|
throw lostError();
|
|
156
|
-
if (unconfirmed === null)
|
|
164
|
+
if (unconfirmed === null && Date.now() < confirmedUntil)
|
|
157
165
|
return;
|
|
158
166
|
try {
|
|
159
167
|
if (!heartbeat()) {
|
|
@@ -161,6 +169,7 @@ export function controllerLeaseMonitor(heartbeat) {
|
|
|
161
169
|
throw lostError();
|
|
162
170
|
}
|
|
163
171
|
unconfirmed = null;
|
|
172
|
+
confirmedUntil = Date.now() + 14 * 60_000;
|
|
164
173
|
}
|
|
165
174
|
catch (error) {
|
|
166
175
|
if (error instanceof AppError)
|
|
@@ -194,6 +203,7 @@ export async function serveRunController(context, input) {
|
|
|
194
203
|
throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
|
|
195
204
|
confirmManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
|
|
196
205
|
const lease = controllerLeaseMonitor(() => heartbeatManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token }));
|
|
206
|
+
lease.assertConfirmed();
|
|
197
207
|
const heartbeat = setInterval(() => {
|
|
198
208
|
try {
|
|
199
209
|
lease.tick();
|
|
@@ -209,7 +219,8 @@ export async function serveRunController(context, input) {
|
|
|
209
219
|
const manifest = JSON.parse(row.manifest_json);
|
|
210
220
|
const state = JSON.parse(row.state_json);
|
|
211
221
|
const assertPhysicalOwner = () => {
|
|
212
|
-
lease.
|
|
222
|
+
lease.assertNoRenewal();
|
|
223
|
+
assertManagedProcessLease(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
|
|
213
224
|
const current = requireController(context, row.controller_id);
|
|
214
225
|
if (current.owner_pid !== process.pid || current.owner_token !== row.owner_token || !["running", "capturing", "waiting_for_user", "waiting_for_context", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(current.status))
|
|
215
226
|
throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
|
|
@@ -220,21 +231,15 @@ export async function serveRunController(context, input) {
|
|
|
220
231
|
process.stderr.write(`${JSON.stringify({ controller_id: row.controller_id, phase, primary: errorRecord(primaryFailure), secondary: errorRecord(error) })}\n`);
|
|
221
232
|
};
|
|
222
233
|
try {
|
|
223
|
-
context.db.
|
|
224
|
-
try {
|
|
234
|
+
context.db.writeTransaction(() => {
|
|
225
235
|
markControllerDispatchReady(context, row);
|
|
226
236
|
if (!state.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
|
|
227
237
|
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
catch (error) {
|
|
231
|
-
context.db.exec("ROLLBACK TO controller_ready_status; RELEASE controller_ready_status");
|
|
232
|
-
throw error;
|
|
233
|
-
}
|
|
238
|
+
});
|
|
234
239
|
appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
|
|
235
240
|
if (state.recovery)
|
|
236
241
|
await resumeControllerSession(context, row, manifest, state, assertPhysicalOwner);
|
|
237
|
-
await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner);
|
|
242
|
+
await executeController(context, row, manifest, state, assertOwner, assertPhysicalOwner, () => lease.assertConfirmed());
|
|
238
243
|
return controllerReceipt(requireController(context, row.controller_id));
|
|
239
244
|
}
|
|
240
245
|
catch (error) {
|
|
@@ -258,25 +263,19 @@ export async function serveRunController(context, input) {
|
|
|
258
263
|
return controllerReceipt(requireController(context, row.controller_id));
|
|
259
264
|
}
|
|
260
265
|
}
|
|
261
|
-
context.db.exec("SAVEPOINT controller_failure_status");
|
|
262
266
|
try {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
267
|
+
context.db.writeTransaction(() => {
|
|
268
|
+
// A failed capture must not reopen admission when this process exits.
|
|
269
|
+
const changed = context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status NOT IN ('completed','cancelled','superseded')", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
|
|
270
|
+
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
|
|
271
|
+
if (changed.changes && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation) {
|
|
272
|
+
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
|
|
273
|
+
}
|
|
274
|
+
appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
|
|
275
|
+
});
|
|
271
276
|
}
|
|
272
277
|
catch (failure) {
|
|
273
278
|
retainSecondary("persist_controller_failure", failure);
|
|
274
|
-
try {
|
|
275
|
-
context.db.exec("ROLLBACK TO controller_failure_status; RELEASE controller_failure_status");
|
|
276
|
-
}
|
|
277
|
-
catch (rollback) {
|
|
278
|
-
retainSecondary("rollback_controller_failure", rollback);
|
|
279
|
-
}
|
|
280
279
|
}
|
|
281
280
|
}
|
|
282
281
|
catch (secondary) {
|
|
@@ -321,7 +320,9 @@ export function prepareRunControllerAnswer(context, input) {
|
|
|
321
320
|
const operationId = `${row.controller_id}:answer:${input.requestId}`;
|
|
322
321
|
const answerHash = digestBytes(bytes);
|
|
323
322
|
const hash = digest({ pause_id: input.pauseId, answer_sha256: answerHash });
|
|
324
|
-
prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
|
|
323
|
+
const previous = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
|
|
324
|
+
if (previous.prior)
|
|
325
|
+
return { kind: "replay", operationId, status: previous.prior.status };
|
|
325
326
|
return { kind: "create", project, row, bytes, operationId, answerHash, hash };
|
|
326
327
|
}
|
|
327
328
|
function prepareControllerAnswerAcceptance(context, input, projectId, row, operationId, hash) {
|
|
@@ -350,37 +351,26 @@ export function answerRunController(context, input, prepared = prepareRunControl
|
|
|
350
351
|
if (prepared.kind === "replay")
|
|
351
352
|
return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
|
|
352
353
|
const { project, row, bytes, operationId, answerHash, hash } = prepared;
|
|
353
|
-
context.
|
|
354
|
-
|
|
354
|
+
const previous = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
|
|
355
|
+
if (previous.prior)
|
|
356
|
+
return { ok: true, operation_id: operationId, status: previous.prior.status, reused: true };
|
|
357
|
+
const file = path.join(row.state_dir, `answer-${digest(operationId)}-${answerHash}.md`);
|
|
358
|
+
publishImmutableInput(file, bytes, "runtime_request_conflict");
|
|
359
|
+
return context.db.writeTransaction(() => {
|
|
355
360
|
// Recheck only mutable acceptance state under the write transaction; bytes are already prepared.
|
|
356
361
|
const acceptance = prepareControllerAnswerAcceptance(context, input, project.id, row, operationId, hash);
|
|
357
362
|
if (acceptance.prior) {
|
|
358
|
-
context.db.exec("COMMIT");
|
|
359
363
|
return { ok: true, operation_id: operationId, status: acceptance.prior.status, reused: true };
|
|
360
364
|
}
|
|
361
365
|
const { run, stage, workId } = acceptance;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
365
|
-
}
|
|
366
|
-
catch (error) {
|
|
367
|
-
if (error.code !== "EEXIST")
|
|
368
|
-
throw error;
|
|
369
|
-
// A crash before COMMIT may leave the immutable answer file behind.
|
|
370
|
-
if (!fs.readFileSync(file).equals(bytes))
|
|
371
|
-
throw new AppError("runtime_request_conflict", "Persisted answer bytes differ from this retry", 1);
|
|
372
|
-
}
|
|
366
|
+
if (requireController(context, row.controller_id).state_dir !== row.state_dir)
|
|
367
|
+
throw new AppError("controller_owner_mismatch", "Controller input directory changed before acceptance", 1);
|
|
373
368
|
const now = context.now();
|
|
374
369
|
const generation = recoveryGuard(context, project.id, run.id)?.generation ?? row.generation;
|
|
375
370
|
context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, generation, hash, JSON.stringify({ action: "user_answer", pause_id: input.pauseId, stage: stage.stage, work_id: workId, file, sha256: answerHash }), now, now]);
|
|
376
371
|
appendEvent(context, row.controller_id, "answer_accepted", { operation_id: operationId, pause_id: input.pauseId, answer_sha256: answerHash });
|
|
377
|
-
context.db.exec("COMMIT");
|
|
378
372
|
return { ok: true, operation_id: operationId, status: "accepted" };
|
|
379
|
-
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
context.db.exec("ROLLBACK");
|
|
382
|
-
throw error;
|
|
383
|
-
}
|
|
373
|
+
});
|
|
384
374
|
}
|
|
385
375
|
export function prepareRunControllerContext(context, input) {
|
|
386
376
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(input.requestId) || !/^[a-z][a-z-]*$/.test(input.stage) || !/^[a-f0-9]{64}$/.test(input.contextSha256))
|
|
@@ -405,8 +395,19 @@ export function supplyRunControllerContext(context, input, prepared = prepareRun
|
|
|
405
395
|
if (prepared.kind === "replay")
|
|
406
396
|
return { ok: true, operation_id: prepared.operationId, status: prepared.status, reused: true };
|
|
407
397
|
const { project, operationId, hash, bytes } = prepared;
|
|
408
|
-
context.
|
|
409
|
-
|
|
398
|
+
const preparedOwner = requireController(context, input.controllerId);
|
|
399
|
+
if (preparedOwner.project_id !== project.id || preparedOwner.run_id !== input.runId)
|
|
400
|
+
throw new AppError("controller_owner_mismatch", "Context submission belongs to another RUN", 1);
|
|
401
|
+
const existing = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
|
|
402
|
+
if (existing) {
|
|
403
|
+
if (existing.input_hash !== hash)
|
|
404
|
+
throw new AppError("runtime_request_conflict", "Context request id has different inputs", 1);
|
|
405
|
+
return { ok: true, operation_id: operationId, status: existing.status, reused: true };
|
|
406
|
+
}
|
|
407
|
+
availableControllerContext(context, input, preparedOwner);
|
|
408
|
+
const file = path.join(preparedOwner.state_dir, `${input.stage}-${input.contextSha256}.context.json`);
|
|
409
|
+
publishImmutableInput(file, bytes, "stage_context_replacement");
|
|
410
|
+
return context.db.writeTransaction(() => {
|
|
410
411
|
const row = requireController(context, input.controllerId);
|
|
411
412
|
if (row.project_id !== project.id || row.run_id !== input.runId)
|
|
412
413
|
throw new AppError("controller_owner_mismatch", "Context submission belongs to another RUN", 1);
|
|
@@ -414,32 +415,18 @@ export function supplyRunControllerContext(context, input, prepared = prepareRun
|
|
|
414
415
|
if (prior) {
|
|
415
416
|
if (prior.input_hash !== hash)
|
|
416
417
|
throw new AppError("runtime_request_conflict", "Context request id has different inputs", 1);
|
|
417
|
-
context.db.exec("COMMIT");
|
|
418
418
|
return { ok: true, operation_id: operationId, status: prior.status, reused: true };
|
|
419
419
|
}
|
|
420
420
|
const manifest = availableControllerContext(context, input, row);
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
|
|
424
|
-
}
|
|
425
|
-
catch (error) {
|
|
426
|
-
if (error.code !== "EEXIST")
|
|
427
|
-
throw error;
|
|
428
|
-
if (!fs.readFileSync(file).equals(bytes))
|
|
429
|
-
throw new AppError("stage_context_replacement", "Unpublished context bytes differ from this request", 1);
|
|
430
|
-
}
|
|
421
|
+
if (row.state_dir !== preparedOwner.state_dir)
|
|
422
|
+
throw new AppError("controller_owner_mismatch", "Controller input directory changed before acceptance", 1);
|
|
431
423
|
manifest.contexts[input.stage] = { file, sha256: input.contextSha256 };
|
|
432
424
|
const now = context.now();
|
|
433
425
|
context.db.run("UPDATE run_controllers SET manifest_json = ?, updated_at = ? WHERE controller_id = ?", [JSON.stringify(manifest), now, row.controller_id]);
|
|
434
426
|
context.db.run("INSERT INTO run_controller_operations (operation_id, controller_id, generation, input_hash, input_json, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)", [operationId, row.controller_id, row.generation, hash, JSON.stringify({ action: "stage_context", stage: input.stage, file, sha256: input.contextSha256 }), now, now]);
|
|
435
427
|
appendEvent(context, row.controller_id, "context_accepted", { operation_id: operationId, stage: input.stage, context_sha256: input.contextSha256 });
|
|
436
|
-
context.db.exec("COMMIT");
|
|
437
428
|
return { ok: true, operation_id: operationId, status: "accepted" };
|
|
438
|
-
}
|
|
439
|
-
catch (error) {
|
|
440
|
-
context.db.exec("ROLLBACK");
|
|
441
|
-
throw error;
|
|
442
|
-
}
|
|
429
|
+
});
|
|
443
430
|
}
|
|
444
431
|
function availableControllerContext(context, input, row) {
|
|
445
432
|
assertRunMutationAllowed(context, row.project_id, input.runId, row.generation);
|
|
@@ -450,7 +437,7 @@ function availableControllerContext(context, input, row) {
|
|
|
450
437
|
throw new AppError("stage_context_replacement", "Controller already has an immutable context for this stage", 1);
|
|
451
438
|
return manifest;
|
|
452
439
|
}
|
|
453
|
-
async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner) {
|
|
440
|
+
async function executeController(context, row, manifest, state, assertOwnership, assertPhysicalOwner, renewLease) {
|
|
454
441
|
assertOwnership();
|
|
455
442
|
const observer = { ...context, db: getDatabase(context.ddFlowHome, "read_existing") };
|
|
456
443
|
const checkOutcomes = () => assertLifecycleOutcomes(observer, { projectRoot: manifest.request.project_root, runId: row.run_id, generation: row.generation });
|
|
@@ -578,6 +565,9 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
578
565
|
monitor.unref();
|
|
579
566
|
try {
|
|
580
567
|
for (;;) {
|
|
568
|
+
// Renewal touches the resource registry; never do it from a callback that
|
|
569
|
+
// may already own the RUN writer.
|
|
570
|
+
renewLease();
|
|
581
571
|
assertOwner();
|
|
582
572
|
if (state.current_session !== null && !state.sessions[state.current_session].stopped)
|
|
583
573
|
bindInvocationScope(state.sessions[state.current_session]);
|
|
@@ -623,7 +613,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
623
613
|
// The accepted answer is logical intent; only its generation-bound
|
|
624
614
|
// adapter operation represents a physical dispatch that stop must drain.
|
|
625
615
|
const command = managedLifecycleCommand(executionContext, `${flowCommand(executionContext)} stage resume ${run.id} --stage ${next.stage} --work ${input.work_id} --project-root ${quote([run.project_root])} --answer-file ${quote([input.file])} --json`, { replaceUnobservedAnswer: true });
|
|
626
|
-
const receipt = await prompt(state.sessions[state.current_session],
|
|
616
|
+
const receipt = await prompt(state.sessions[state.current_session], controllerStageResumePrompt(pause.id, command), `${answer.operation_id}:prompt:${row.generation}`);
|
|
627
617
|
const afterAnswer = getFlowRunStatus(executionContext, { projectRoot: run.project_root, runId: run.id });
|
|
628
618
|
if (afterAnswer.index.stage_runs.some(stage => stage.status === "paused" && stage.pause?.id === pause.id)) {
|
|
629
619
|
throw new AppError("controller_answer_not_applied", "The answer Turn completed without resuming the assigned HITL pause", 1, { pause_id: pause.id, operation_id: answer.operation_id, stage: next.stage, effect: "no_progress" });
|
|
@@ -650,7 +640,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
650
640
|
loadExternalStageContext({ file: stageContext.file, expectedSha256: stageContext.sha256, stage: next.stage });
|
|
651
641
|
const continuation = JSON.stringify(["start_stage", next.stage, next.attempt ?? null]);
|
|
652
642
|
retainControllerContinuation(state, continuation, { code: "stage_entry_nonprogressing", message: "The coordinator returned without starting the assigned Stage", details: { stage: next.stage, attempt: next.attempt ?? null, last_receipt: state.last_receipt ?? null } });
|
|
653
|
-
const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
|
|
643
|
+
const transition = resolveStageTransition({ ddFlowHome: context.ddFlowHome, policy, profiles, ...(state.stage ? { fromStage: state.stage } : {}), stage: next.stage, projectRoot: run.project_root, workspaceRoot: run.workspace_root, stageSessionMode: manifest.execution_profile.settings.stage_session_mode, mergeMode: manifest.execution_profile.settings.merge_mode });
|
|
654
644
|
if (next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "server" && !state.merge_claim) {
|
|
655
645
|
const request = context.db.get("SELECT merge_request_id, status FROM merge_requests WHERE project_id = ? AND run_id = ? ORDER BY created_at DESC, merge_request_id DESC LIMIT 1", [row.project_id, row.run_id]);
|
|
656
646
|
if (!request || request.status !== "queued")
|
|
@@ -665,8 +655,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
665
655
|
saveState(context, row, state);
|
|
666
656
|
appendEvent(context, row.controller_id, "merge_claimed", { request_id: claim.request.merge_request_id });
|
|
667
657
|
}
|
|
658
|
+
const stageCwd = stageExecutionCwd({ stage: next.stage, projectRoot: run.project_root, workspaceRoot: run.workspace_root, ...(state.merge_claim ? { mergeTargetWorkspace: state.merge_claim.target_workspace } : {}) });
|
|
668
659
|
let session = state.current_session === null ? null : state.sessions[state.current_session];
|
|
669
|
-
if (
|
|
660
|
+
if (session && next.stage === "merge" && manifest.execution_profile.settings.merge_mode === "same_session" && path.resolve(session.cwd) !== stageCwd) {
|
|
661
|
+
throw new AppError("merge_session_workspace_conflict", "Same-session MERGE cannot change the native Session working directory", 1, { session_cwd: session.cwd, target_cwd: stageCwd });
|
|
662
|
+
}
|
|
663
|
+
if (!session || transition.session_mode === "new_session" || !sameSessionProfile(session.profile, transition.coordinator) || path.resolve(session.cwd) !== stageCwd) {
|
|
670
664
|
if (session)
|
|
671
665
|
await stopSession(session);
|
|
672
666
|
assertOwner();
|
|
@@ -677,7 +671,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
677
671
|
owner.role = "merge";
|
|
678
672
|
owner.work_id = state.merge_claim.work_id;
|
|
679
673
|
}
|
|
680
|
-
session = { id: "", profile: transition.coordinator, state_dir: directory, cwd:
|
|
674
|
+
session = { id: "", profile: transition.coordinator, state_dir: directory, cwd: stageCwd, journal: path.join(directory, "adapter.events.jsonl"), owner };
|
|
681
675
|
if (runtimeHarness(session.profile) === "codex-desktop") {
|
|
682
676
|
session.codex_home = path.join(directory, "codex-home");
|
|
683
677
|
initCodexHome(context, { projectRoot: run.project_root, profile: `controller-${digest(directory).slice(0, 16)}`, targetHome: session.codex_home, ...(context.env.CODEX_HOME ? { sourceHome: context.env.CODEX_HOME } : {}) });
|
|
@@ -722,9 +716,10 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
722
716
|
const fanout = await nextControllerFanout(executionContext, { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles });
|
|
723
717
|
assertOwner();
|
|
724
718
|
if (fanout.kind === "none") {
|
|
725
|
-
|
|
719
|
+
assertNoCorrelatedRejection(context, { projectId: row.project_id, runId: row.run_id, generation: row.generation, stage: next.stage, turn: state.turns });
|
|
720
|
+
const cause = currentLifecycleFailure(executionContext, { runId: run.id, generation: row.generation, stage: next.stage, turn: state.turns, projectId: row.project_id, projectRoot: run.project_root });
|
|
726
721
|
if (cause)
|
|
727
|
-
throw new AppError(cause.code, cause.message, 1, { ...cause.details, invocation_id: cause.invocation_id, controller_wrapper: "incomplete_subject_turn", stage: next.stage });
|
|
722
|
+
throw new AppError(cause.code, cause.message, 1, { ...cause.details, ...(cause.invocation_id ? { invocation_id: cause.invocation_id } : {}), ...(cause.hook_event_id ? { hook_event_id: cause.hook_event_id } : {}), controller_wrapper: "incomplete_subject_turn", stage: next.stage });
|
|
728
723
|
throw new AppError("incomplete_subject_turn", "Managed Turn ended without a completed Stage or declared continuation", 1, { stage: next.stage });
|
|
729
724
|
}
|
|
730
725
|
if (fanout.kind === "wait") {
|
|
@@ -4,8 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { createContext } from "../runtime/context.js";
|
|
5
5
|
import { AppError } from "../shared/errors.js";
|
|
6
6
|
import { canonicalPath } from "../storage/paths.js";
|
|
7
|
+
import { importGuardPath, rearmCompletedSnapshotImport } from "../storage/import-guard.js";
|
|
7
8
|
import { installCurrentEngine, upgradeForkedRunEngine, prepareForkEngine } from "./engines.js";
|
|
8
|
-
import { restoreEvalRunSnapshot, prepareEvalRunSnapshot, prepareEvalRunRestore } from "./eval-snapshots.js";
|
|
9
|
+
import { restoreEvalRunSnapshot, prepareEvalRunSnapshot, prepareEvalRunRestore, restoredWorkspaceRoot } from "./eval-snapshots.js";
|
|
9
10
|
export function prepareRunFork(input) {
|
|
10
11
|
const requestId = requireId(input.requestId, "request-id");
|
|
11
12
|
const snapshot = canonicalPath(input.snapshot, false);
|
|
@@ -47,7 +48,8 @@ function assertForkOutput(output, intentFile, intent) {
|
|
|
47
48
|
if (fs.existsSync(output) && fs.readdirSync(output).length > 0) {
|
|
48
49
|
if (!fs.existsSync(intentFile) || hash(readJson(intentFile).intent) !== hash(intent))
|
|
49
50
|
throw new AppError("fork_output_not_empty", "Fork output must be empty", 2, { output });
|
|
50
|
-
const
|
|
51
|
+
const guardName = path.basename(importGuardPath(path.join(output, "dd-flow-home")));
|
|
52
|
+
const unknown = fs.readdirSync(output).filter(name => ![".fork-intent.json", "project", "dd-flow-home", guardName].includes(name));
|
|
51
53
|
if (unknown.length)
|
|
52
54
|
throw new AppError("fork_retry_inspect_required", "Fork output contains data not owned by the interrupted fork", 1, { output, unknown });
|
|
53
55
|
}
|
|
@@ -57,16 +59,18 @@ export function forkRunFromSnapshot(context, input, prepared = prepareRunFork(in
|
|
|
57
59
|
return prepared.receipt;
|
|
58
60
|
const { manifest, snapshot, output, intent, intentFile, receiptFile, requestId } = prepared;
|
|
59
61
|
assertForkOutput(output, intentFile, intent);
|
|
62
|
+
const projectRoot = path.join(output, "project");
|
|
63
|
+
const home = path.join(output, "dd-flow-home");
|
|
64
|
+
const prearmedImportId = rearmCompletedSnapshotImport({ home, projectRoot,
|
|
65
|
+
workspaceRoot: restoredWorkspaceRoot(manifest, projectRoot, home), sourceManifestSha256: intent.snapshot_sha256 });
|
|
60
66
|
for (const name of ["project", "dd-flow-home"])
|
|
61
67
|
fs.rmSync(path.join(output, name), { recursive: true, force: true });
|
|
62
68
|
fs.mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
63
69
|
writeJsonAtomic(intentFile, { schema_id: "dd-flow/run-fork-intent@1", intent });
|
|
64
|
-
const projectRoot = path.join(output, "project");
|
|
65
|
-
const home = path.join(output, "dd-flow-home");
|
|
66
70
|
fs.mkdirSync(projectRoot, { recursive: true, mode: 0o700 });
|
|
67
|
-
const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
71
|
+
const targetContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" }, "initialize", prearmedImportId ?? undefined);
|
|
68
72
|
const restoreInput = { snapshot, projectRoot };
|
|
69
|
-
const restored = restoreEvalRunSnapshot(targetContext, restoreInput, prepareEvalRunRestore(targetContext, restoreInput, manifest));
|
|
73
|
+
const restored = restoreEvalRunSnapshot(targetContext, restoreInput, prepareEvalRunRestore(targetContext, restoreInput, manifest), prearmedImportId ?? undefined);
|
|
70
74
|
const restoredContext = createContext({ ...context.env, DD_FLOW_HOME: home, DD_FLOW_ENGINE_MODE: "1" });
|
|
71
75
|
try {
|
|
72
76
|
const runHome = String(restored.run_home);
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import { reconcileSessionCoverageRows } from "../domain/session-coverage.js";
|
|
4
3
|
import { publicSessionIdentity } from "./session-identity.js";
|
|
4
|
+
import { publishRunProjections } from "./runs.js";
|
|
5
5
|
export function refreshRunSessionProjection(context, projectId, runId) {
|
|
6
|
+
// Review children can finish concurrently in separate processes. Reserve the
|
|
7
|
+
// writer before reading the RUN index so a sibling cannot invalidate it.
|
|
8
|
+
context.db.writeTransaction(() => refreshRunSessionProjectionLocked(context, projectId, runId), "run-projection.refresh");
|
|
9
|
+
}
|
|
10
|
+
function refreshRunSessionProjectionLocked(context, projectId, runId) {
|
|
6
11
|
const run = context.db.get("SELECT id, flow_kind, status, project_root, subject_type, subject_id, runtime_path, run_index_path, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, runId]);
|
|
7
12
|
if (!run)
|
|
8
13
|
return;
|
|
@@ -27,7 +32,7 @@ export function refreshRunSessionProjection(context, projectId, runId) {
|
|
|
27
32
|
missing: coverage.missing_unit_ids,
|
|
28
33
|
diagnostics: coverage.diagnostics
|
|
29
34
|
};
|
|
30
|
-
const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root) : {};
|
|
35
|
+
const plan = run.subject_type === "protocol" ? planProjection(context, projectId, run.subject_id, run.project_root, index) : {};
|
|
31
36
|
const workers = workerProjection(context, projectId, runId);
|
|
32
37
|
const vnext = run.flow_kind === "vnext_specify" || run.flow_kind === "vnext_protocolize";
|
|
33
38
|
const hadLegacyCoverage = vnext && ("session_coverage" in index || "usage_coverage" in index);
|
|
@@ -42,8 +47,8 @@ export function refreshRunSessionProjection(context, projectId, runId) {
|
|
|
42
47
|
&& JSON.stringify(index.works ?? []) === JSON.stringify(works)
|
|
43
48
|
&& index.root_work_id === rootWorkId
|
|
44
49
|
&& (vnext || JSON.stringify(index.session_coverage) === JSON.stringify(sessionCoverage))
|
|
45
|
-
&& JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref)
|
|
46
|
-
&& JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress)
|
|
50
|
+
&& (!plan.plan_ref || JSON.stringify(index.plan_ref) === JSON.stringify(plan.plan_ref))
|
|
51
|
+
&& (!plan.plan_progress || JSON.stringify(index.plan_progress) === JSON.stringify(plan.plan_progress))
|
|
47
52
|
&& (vnext || JSON.stringify(index.workers ?? {}) === JSON.stringify(workers))
|
|
48
53
|
&& !hadLegacyCoverage)
|
|
49
54
|
return;
|
|
@@ -59,44 +64,23 @@ export function refreshRunSessionProjection(context, projectId, runId) {
|
|
|
59
64
|
if (!vnext)
|
|
60
65
|
index.workers = workers;
|
|
61
66
|
index.updated_at = context.now();
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
: 1;
|
|
68
|
-
index.runtime_revision = runtimeRevision;
|
|
69
|
-
const authoritative = { ...(runtime ?? {}), ...index, schema_id: String(index.schema_id ?? runtime?.schema_id ?? "dd-flow/flow-run@3") };
|
|
70
|
-
authoritative.sessions = sessions;
|
|
71
|
-
authoritative.works = works;
|
|
72
|
-
authoritative.root_work_id = rootWorkId;
|
|
73
|
-
if (vnext) {
|
|
74
|
-
delete authoritative.session_coverage;
|
|
75
|
-
delete authoritative.usage_coverage;
|
|
76
|
-
delete authoritative.workers;
|
|
77
|
-
delete authoritative.current_stage;
|
|
78
|
-
delete authoritative.attempts;
|
|
79
|
-
}
|
|
80
|
-
else
|
|
81
|
-
authoritative.session_coverage = sessionCoverage;
|
|
82
|
-
if (plan.plan_ref)
|
|
83
|
-
authoritative.plan_ref = plan.plan_ref;
|
|
84
|
-
if (plan.plan_progress)
|
|
85
|
-
authoritative.plan_progress = plan.plan_progress;
|
|
86
|
-
if (!vnext)
|
|
87
|
-
authoritative.workers = workers;
|
|
88
|
-
authoritative.updated_at = index.updated_at;
|
|
89
|
-
authoritative.runtime_revision = runtimeRevision;
|
|
90
|
-
if (run.status !== "discarded" || fs.existsSync(run.runtime_path))
|
|
91
|
-
writeJson(run.runtime_path, authoritative);
|
|
92
|
-
context.db.run("UPDATE runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ?", [JSON.stringify(index), index.updated_at, projectId, runId]);
|
|
67
|
+
index.runtime_revision = typeof index.runtime_revision === "number" ? index.runtime_revision + 1 : 1;
|
|
68
|
+
const updated = context.db.run("UPDATE runs SET index_json = ?, updated_at = ? WHERE project_id = ? AND id = ? AND index_json = ?", [JSON.stringify(index), index.updated_at, projectId, runId, run.index_json]);
|
|
69
|
+
if (updated.changes !== 1)
|
|
70
|
+
throw new Error("RUN projection changed during preparation");
|
|
71
|
+
context.db.afterCommit(() => publishRunProjections(context, projectId, runId));
|
|
93
72
|
}
|
|
94
|
-
function planProjection(context, projectId, protocolId, projectRoot) {
|
|
95
|
-
const binding = context.db.get("SELECT plan_path, plan_revision, plan_sha256 FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
|
|
73
|
+
function planProjection(context, projectId, protocolId, projectRoot, index) {
|
|
74
|
+
const binding = context.db.get("SELECT plan_id, plan_path, plan_revision, plan_sha256 FROM plan_bindings WHERE project_id = ? AND protocol_id = ?", [projectId, protocolId]);
|
|
96
75
|
if (!binding)
|
|
97
76
|
return {};
|
|
98
77
|
const planPath = path.relative(projectRoot, binding.plan_path).split(path.sep).join("/");
|
|
99
|
-
const
|
|
78
|
+
const priorRef = index.plan_ref && typeof index.plan_ref === "object" && !Array.isArray(index.plan_ref) ? index.plan_ref : {};
|
|
79
|
+
const planId = binding.plan_id ?? (typeof priorRef.plan_id === "string" ? priorRef.plan_id : null);
|
|
80
|
+
// A pre-migration binding without a RUN plan_ref needs canonical rebind before
|
|
81
|
+
// this projection can be updated; never invent a plan identifier.
|
|
82
|
+
if (!planId)
|
|
83
|
+
return {};
|
|
100
84
|
const progress = Object.fromEntries(context.db.all("SELECT item_id, status, summary, evidence_json FROM plan_progress WHERE project_id = ? AND protocol_id = ? ORDER BY item_id", [projectId, protocolId]).map((row) => [row.item_id, {
|
|
101
85
|
status: row.status,
|
|
102
86
|
...(row.summary ? { summary: row.summary } : {}),
|
|
@@ -112,18 +96,6 @@ function planProjection(context, projectId, protocolId, projectRoot) {
|
|
|
112
96
|
plan_progress: progress
|
|
113
97
|
};
|
|
114
98
|
}
|
|
115
|
-
function readPlanId(planPath) {
|
|
116
|
-
try {
|
|
117
|
-
const value = JSON.parse(fs.readFileSync(planPath, "utf8"));
|
|
118
|
-
if (value && typeof value === "object" && !Array.isArray(value) && typeof value.plan_id === "string") {
|
|
119
|
-
return value.plan_id;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
// The canonical validator owns malformed-plan errors; projection stays bounded.
|
|
124
|
-
}
|
|
125
|
-
return "unknown-plan";
|
|
126
|
-
}
|
|
127
99
|
function workerProjection(context, projectId, runId) {
|
|
128
100
|
return Object.fromEntries(context.db.all(`SELECT j.job_id, j.plan_item_id, j.status, j.worker_session_id, s.harness, s.provider_session_id
|
|
129
101
|
FROM flow_jobs j LEFT JOIN sessions s ON s.project_id = j.project_id AND s.session_id = j.worker_session_id
|
|
@@ -182,17 +154,3 @@ function parseCoverageUnits(value) {
|
|
|
182
154
|
return [];
|
|
183
155
|
}
|
|
184
156
|
}
|
|
185
|
-
function readJson(file) {
|
|
186
|
-
try {
|
|
187
|
-
return fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8")) : undefined;
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
function writeJson(file, value) {
|
|
194
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
195
|
-
const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
196
|
-
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
197
|
-
fs.renameSync(temporary, file);
|
|
198
|
-
}
|
|
@@ -4,16 +4,17 @@ import { AppError } from "../shared/errors.js";
|
|
|
4
4
|
import { resolveProjectRoot } from "../storage/paths.js";
|
|
5
5
|
import { requireProjectByRoot } from "./projects.js";
|
|
6
6
|
import { acceptanceCommand, recoveryGuard, recoveryPacketReceipt, requireRecoveryRun } from "./run-recovery.js";
|
|
7
|
-
import { processIsAlive } from "./managed-processes.js";
|
|
7
|
+
import { managedProcessBinding, processIsAlive, resourceHome } from "./managed-processes.js";
|
|
8
|
+
import { getResourceDatabase } from "../storage/database.js";
|
|
8
9
|
import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
10
|
+
import { lifecycleRetryInstruction } from "../harness-runtime/lib/delegation-instructions.mjs";
|
|
9
11
|
/** Pin retained Session loading and its first acknowledgement Turn, never a general fence bypass. */
|
|
10
12
|
export function prepareRunRecoveryDispatch(context, input) {
|
|
11
13
|
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(input.operationId) || !path.isAbsolute(input.stateDir))
|
|
12
14
|
throw new AppError("usage", "Recovery dispatch requires an operation id and absolute state directory", 2);
|
|
13
15
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
14
16
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
15
|
-
context.db.
|
|
16
|
-
try {
|
|
17
|
+
const prepared = context.db.readSnapshot(() => {
|
|
17
18
|
const guard = recoveryGuard(context, project.id, run.id);
|
|
18
19
|
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
|
|
19
20
|
if (!guard || guard.recovery_id !== input.recoveryId || !binding || !["resuming", "recovered"].includes(guard.status))
|
|
@@ -23,8 +24,7 @@ export function prepareRunRecoveryDispatch(context, input) {
|
|
|
23
24
|
const prior = JSON.parse(binding.runtime_dispatch_json);
|
|
24
25
|
if (prior.state_dir !== input.stateDir || prior.operation_id !== input.operationId)
|
|
25
26
|
throw new AppError("recovery_binding_mismatch", "Recovery dispatch is already pinned to another operation", 1);
|
|
26
|
-
|
|
27
|
-
return prior;
|
|
27
|
+
return { dispatch: prior, guard, binding };
|
|
28
28
|
}
|
|
29
29
|
if (guard.status !== "resuming" || binding.status !== "prepared")
|
|
30
30
|
throw new AppError("recovery_state_changed", "Recovery acknowledgement is no longer awaiting dispatch", 1);
|
|
@@ -34,16 +34,27 @@ export function prepareRunRecoveryDispatch(context, input) {
|
|
|
34
34
|
const metadata = JSON.parse(record.metadata_json);
|
|
35
35
|
if (metadata.state_dir !== input.stateDir || metadata.run_generation !== guard.generation || metadata.dispatch_barrier !== "run-generation@1" || !["subject", "merge"].includes(metadata.role ?? ""))
|
|
36
36
|
throw new AppError("recovery_runtime_mismatch", "Replacement daemon lacks this recovery generation's native dispatch barrier", 1);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
catch (error) {
|
|
44
|
-
context.db.exec("ROLLBACK");
|
|
45
|
-
throw error;
|
|
37
|
+
return { guard, binding, record };
|
|
38
|
+
});
|
|
39
|
+
let dispatch = prepared.dispatch;
|
|
40
|
+
if (!dispatch) {
|
|
41
|
+
const prompt = `Resume this retained RUN in its existing native Session. Your first tool call must be this exact standalone command:\n\n${acceptanceCommand(context, run.id, prepared.guard.recovery_id, run.project_root)}\n\nFollow the verified recovery packet returned by that command. Preserve completed Work and existing HITL; do not start a successor Stage yourself. Stop at the Stage or declared Work-graph boundary.\n\n${lifecycleRetryInstruction}\n`;
|
|
42
|
+
dispatch = { process_id: prepared.record.id, owner_id: prepared.record.owner_id, state_dir: input.stateDir, daemon_id: prepared.binding.daemon_id, operation_id: input.operationId, resume_operation_id: `${input.operationId}:load`, prompt_sha256: crypto.createHash("sha256").update(prompt).digest("hex"), prompt };
|
|
46
43
|
}
|
|
44
|
+
const registry = prepared.record ? getResourceDatabase(resourceHome(context)) : undefined;
|
|
45
|
+
return context.db.writeTransaction(() => {
|
|
46
|
+
const guard = recoveryGuard(context, project.id, run.id);
|
|
47
|
+
const binding = context.db.get("SELECT * FROM run_recovery_bindings WHERE recovery_id = ?", [input.recoveryId]);
|
|
48
|
+
if (JSON.stringify(guard) !== JSON.stringify(prepared.guard) || JSON.stringify(binding) !== JSON.stringify(prepared.binding))
|
|
49
|
+
throw new AppError("recovery_state_changed", "Recovery generation changed while dispatch was prepared", 1);
|
|
50
|
+
if (prepared.record) {
|
|
51
|
+
const current = registry.get("SELECT * FROM managed_processes WHERE id = ?", [prepared.record.id]);
|
|
52
|
+
if (!current || current.state !== "running" || current.lease_expires_at <= context.now() || managedProcessBinding(current, resourceHome(context)).registration_sha256 !== managedProcessBinding(prepared.record, resourceHome(context)).registration_sha256)
|
|
53
|
+
throw new AppError("recovery_runtime_mismatch", "Recovery replacement daemon lease changed before dispatch publication", 1);
|
|
54
|
+
context.db.run("UPDATE run_recovery_bindings SET runtime_dispatch_json = ? WHERE recovery_id = ? AND status = 'prepared' AND runtime_dispatch_json IS NULL", [JSON.stringify(dispatch), prepared.binding.recovery_id]);
|
|
55
|
+
}
|
|
56
|
+
return dispatch;
|
|
57
|
+
});
|
|
47
58
|
}
|
|
48
59
|
/** The caller has already checked PID/start/lease and runtime-home identity. */
|
|
49
60
|
export function recoveryRuntimeAdmission(context, record, input) {
|