@deksden-com/dd-flow-cli 0.9.0-beta.62 → 0.9.0-beta.64

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/build-info.json +6 -6
  3. package/dist/cli/run-cli.js +45 -4
  4. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +1 -1
  5. package/dist/harness-runtime/lib/dd-zcode.mjs +2 -1
  6. package/dist/harness-runtime/lib/native-hook-command.mjs +1 -0
  7. package/dist/harness-runtime/lib/process-json.mjs +15 -6
  8. package/dist/services/cleanup.js +1 -1
  9. package/dist/services/code-checks.js +1 -1
  10. package/dist/services/eval-snapshots.js +2 -2
  11. package/dist/services/harness-adapter.js +61 -9
  12. package/dist/services/hooks.js +26 -0
  13. package/dist/services/lanes.js +1 -1
  14. package/dist/services/lifecycle-invocations.js +194 -22
  15. package/dist/services/managed-processes.js +2 -2
  16. package/dist/services/merge-queue.js +6 -6
  17. package/dist/services/merge-server.js +1 -1
  18. package/dist/services/migrations.js +1 -1
  19. package/dist/services/projects.js +1 -1
  20. package/dist/services/protocols.js +2 -2
  21. package/dist/services/run-control-worker.js +3 -3
  22. package/dist/services/run-control.js +24 -2
  23. package/dist/services/run-controller-adapter.js +8 -3
  24. package/dist/services/run-controller-capture.js +2 -2
  25. package/dist/services/run-controller-process.js +2 -2
  26. package/dist/services/run-controller-recovery.js +3 -3
  27. package/dist/services/run-controller.js +297 -180
  28. package/dist/services/run-recovery-runtime.js +1 -1
  29. package/dist/services/run-recovery.js +3 -3
  30. package/dist/services/runs.js +13 -14
  31. package/dist/services/runtime-budget.js +4 -4
  32. package/dist/services/runtime-scope-control.js +5 -5
  33. package/dist/services/runtime-scope-resume.js +7 -7
  34. package/dist/services/runtime-scope-worker.js +2 -2
  35. package/dist/services/stage-pause.js +2 -2
  36. package/dist/services/vnext-code-review.js +9 -4
  37. package/dist/services/vnext-code.js +10 -3
  38. package/dist/services/vnext-merge.js +16 -21
  39. package/dist/services/vnext-plan-review.js +4 -2
  40. package/dist/services/vnext-plan.js +1 -1
  41. package/dist/services/work-registry.js +198 -121
  42. package/dist/storage/database.js +112 -20
  43. package/dist/storage/writer-contract.js +2 -1
  44. package/dist/storage/writer-migration.js +2 -2
  45. package/package.json +1 -1
@@ -1,12 +1,18 @@
1
1
  import crypto from "node:crypto";
2
2
  import path from "node:path";
3
+ import fs from "node:fs";
3
4
  import { quote } from "shell-quote";
4
5
  import { setTimeout as delay } from "node:timers/promises";
5
6
  import { AppError } from "../shared/errors.js";
6
7
  import { commandOption, parseLifecycleCommand, shellSuffixOffset } from "./lifecycle-command.js";
7
8
  import { requireProjectByRoot } from "./projects.js";
8
9
  import { assertRunMutationAllowed, assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryGuard } from "./run-recovery.js";
9
- import { requireManagedDaemonBinding } from "./managed-daemon-binding.js";
10
+ import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
11
+ import { managedProcessStatus } from "./managed-processes.js";
12
+ import { appendEvent } from "./run-controller-state.js";
13
+ import { recoverCommittedWorkStart } from "./work-registry.js";
14
+ import { observeCommandHook } from "./hooks.js";
15
+ import { runtimeHarness } from "./execution-policy.js";
10
16
  // Conclusive result/check rejections, never transport, ownership, registry or
11
17
  // unknown-effect failures. These validators reject before accepting the result.
12
18
  export function isRetryableLifecycleRejection(code, details = {}) {
@@ -52,6 +58,164 @@ function load(context, id) {
52
58
  export function lifecycleInvocationScope(context, id) {
53
59
  return JSON.parse(load(context, id).scope_json);
54
60
  }
61
+ /** Read-only controller observation of a retained lifecycle outcome. */
62
+ export function lifecycleInvocationOutcome(context, id) {
63
+ const row = load(context, id);
64
+ return { status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null };
65
+ }
66
+ /** Current failures are read from runtime authority, never from model text. */
67
+ export function assertLifecycleOutcomes(context, scope) {
68
+ const project = requireProjectByRoot(context, scope.projectRoot);
69
+ const diagnostics = lifecycleDiagnostics(context, project.id, scope.runId, scope.generation);
70
+ if (fs.existsSync(diagnostics)) {
71
+ for (const name of fs.readdirSync(diagnostics).filter(name => name.endsWith(".json"))) {
72
+ const record = JSON.parse(fs.readFileSync(path.join(diagnostics, name), "utf8"));
73
+ if (record.project_id !== project.id || record.run_id !== scope.runId || record.generation !== scope.generation)
74
+ throw new AppError("lifecycle_outcome_conflict", "Lifecycle diagnostic scope differs", 1);
75
+ throw new AppError(record.error?.code ?? "lifecycle_outcome_unknown", record.error?.message ?? "Lifecycle settlement could not be persisted", 1, { ...record.error?.details, lifecycle_outcome: record, diagnostic_artifact: path.join(diagnostics, name) });
76
+ }
77
+ }
78
+ const columns = context.db.all("PRAGMA table_info(hook_events)");
79
+ if (columns.some(column => column.name === "outcome_json")) {
80
+ for (const row of context.db.all("SELECT outcome_json FROM hook_events WHERE project_id = ? AND json_extract(outcome_json, '$.run_id') = ? AND json_extract(outcome_json, '$.generation') = ? ORDER BY id", [project.id, scope.runId, scope.generation])) {
81
+ const outcome = JSON.parse(row.outcome_json);
82
+ if (outcome.disposition === "retrying" && outcome.deadline <= Date.now())
83
+ throw new AppError("lifecycle_outcome_unknown", "Storage retry has no terminal receipt; reconcile its committed evidence", 1, { lifecycle_outcome: outcome, effect: "unknown" });
84
+ if (outcome.disposition === "fatal")
85
+ throw new AppError(outcome.error.code, outcome.error.message, 1, { ...outcome.error.details, lifecycle_outcome: outcome });
86
+ }
87
+ }
88
+ if (!context.db.get("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'lifecycle_invocations'"))
89
+ return;
90
+ const rows = context.db.all(`SELECT a.* FROM lifecycle_invocations a
91
+ WHERE json_extract(a.scope_json, '$.projectRoot') = ? AND json_extract(a.scope_json, '$.runId') = ?
92
+ AND json_extract(a.scope_json, '$.generation') = ? AND a.status = 'settled'
93
+ AND NOT EXISTS (SELECT 1 FROM lifecycle_invocations newer WHERE newer.scope_json = a.scope_json AND newer.fingerprint = a.fingerprint AND newer.rowid > a.rowid)
94
+ ORDER BY a.rowid`, [scope.projectRoot, scope.runId, scope.generation]);
95
+ for (const row of rows) {
96
+ const error = (row.outcome_json ? JSON.parse(row.outcome_json) : null)?.error;
97
+ if (typeof error?.code === "string" && typeof error.details?.retry_command !== "string")
98
+ throw new AppError(error.code, typeof error.message === "string" ? error.message : "Mandatory lifecycle failed", 1, { ...(error.details ?? {}), invocation_id: row.id });
99
+ }
100
+ }
101
+ /** Hooks already own native identity. Retain command outcomes there, with an
102
+ * atomic controller notification; missing notifications reconcile from the row. */
103
+ export function observeLifecycleCommand(context, command) {
104
+ const parsed = parseLifecycleCommand(command);
105
+ if (parsed.kind !== "standalone")
106
+ return () => { };
107
+ let anchor;
108
+ context.observeLifecycleHook = (projectId, eventId) => {
109
+ if (anchor)
110
+ return;
111
+ const event = context.db.get("SELECT session_id, provider_session_id, parent_session_id, daemon_id, harness FROM hook_events WHERE id = ? AND project_id = ?", [eventId, projectId]);
112
+ if (!event)
113
+ return;
114
+ let runId = context.db.get("SELECT run_id FROM sessions WHERE project_id = ? AND harness = ? AND (session_id = ? OR session_id = ? OR provider_session_id = ?) AND run_id IS NOT NULL ORDER BY session_id = ? DESC LIMIT 1", [projectId, event.harness, event.session_id, event.parent_session_id, event.parent_session_id, event.session_id])?.run_id;
115
+ let generation;
116
+ // A first Stage command precedes logical Session binding. Its native root
117
+ // is already owned by the controller's retained create receipt.
118
+ if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'")) {
119
+ const owners = context.db.all("SELECT run_id, generation, state_json FROM run_controllers WHERE project_id = ? AND status NOT IN ('completed','cancelled','superseded')", [projectId]).filter(owner => {
120
+ const state = JSON.parse(owner.state_json);
121
+ return state.sessions?.some(session => [event.provider_session_id ?? event.session_id, event.parent_session_id].includes(session.id) && runtimeHarness(session.profile) === event.harness);
122
+ });
123
+ if (owners.length > 1)
124
+ return;
125
+ if (owners.length === 1) {
126
+ if (runId && runId !== owners[0].run_id)
127
+ return;
128
+ runId = owners[0].run_id;
129
+ generation = owners[0].generation;
130
+ }
131
+ }
132
+ if (event.daemon_id) {
133
+ for (const record of managedProcessStatus(context)) {
134
+ if (record.project_id !== projectId || !record.run_id || !record.kind.endsWith("-daemon"))
135
+ continue;
136
+ const metadata = JSON.parse(record.metadata_json);
137
+ if (typeof metadata.state_dir !== "string")
138
+ continue;
139
+ let daemonId;
140
+ try {
141
+ daemonId = readNativeDaemonState(metadata.state_dir).daemon_id;
142
+ }
143
+ catch {
144
+ continue;
145
+ } // Discovery of unrelated historical daemons is not authority.
146
+ if (daemonId !== event.daemon_id)
147
+ continue;
148
+ const binding = requireManagedDaemonBinding(context, { stateDir: metadata.state_dir, projectId, runId: record.run_id });
149
+ if (binding.state.daemon_id !== event.daemon_id)
150
+ continue;
151
+ runId = record.run_id;
152
+ generation = metadata.run_generation;
153
+ break;
154
+ }
155
+ }
156
+ if (!runId)
157
+ return;
158
+ anchor = { project_id: projectId, run_id: runId, generation: generation ?? recoveryGuard(context, projectId, runId)?.generation ?? 0, hook_event_id: eventId, session_id: event.session_id, harness: event.harness, operation: parsed.invocation.operation };
159
+ };
160
+ observeCommandHook(context, command);
161
+ const invocationId = commandOption(parsed.invocation, "invocation-id");
162
+ if (!anchor && invocationId) {
163
+ const attempt = load(context, invocationId);
164
+ checkCommand(attempt, command);
165
+ if (attempt.event_key && ["observed", "executing"].includes(attempt.status)) {
166
+ const scope = JSON.parse(attempt.scope_json);
167
+ const project = requireProjectByRoot(context, scope.projectRoot);
168
+ const event = context.db.get("SELECT id FROM hook_events WHERE project_id = ? AND event_key = ?", [project.id, attempt.event_key]);
169
+ if (event)
170
+ context.observeLifecycleHook(project.id, event.id);
171
+ }
172
+ }
173
+ return outcome => {
174
+ delete context.observeLifecycleHook;
175
+ if (!anchor)
176
+ return;
177
+ const retryable = outcome.error && (isRetryableLifecycleRejection(outcome.error.code, outcome.error.details) || typeof outcome.error.details.retry_command === "string");
178
+ const record = { ...anchor, ...outcome, effect: outcome.error ? outcome.error.details.effect ?? (retryable ? "no_effect" : "unknown") : "committed", disposition: outcome.error ? retryable ? "correctable" : "fatal" : "completed" };
179
+ try {
180
+ context.db.writeTransaction(() => {
181
+ const retained = context.db.get("SELECT outcome_json FROM hook_events WHERE id = ?", [anchor.hook_event_id]);
182
+ const previous = retained?.outcome_json ? JSON.parse(retained.outcome_json) : null;
183
+ if (previous?.storage_retry === 1)
184
+ Object.assign(record, { storage_retry: 1, work_id: previous.work_id });
185
+ const encoded = JSON.stringify(record);
186
+ if (retained?.outcome_json && previous?.disposition !== "retrying") {
187
+ if (retained.outcome_json !== encoded)
188
+ throw new AppError("lifecycle_outcome_conflict", "Hook outcome cannot change", 1, { hook_event_id: anchor.hook_event_id });
189
+ return;
190
+ }
191
+ context.db.run("UPDATE hook_events SET outcome_json = ? WHERE id = ?", [encoded, anchor.hook_event_id]);
192
+ if (context.db.get("SELECT 1 FROM sqlite_master WHERE name = 'run_controllers'"))
193
+ for (const controller of context.db.all("SELECT controller_id FROM run_controllers WHERE project_id = ? AND run_id = ? AND generation = ?", [anchor.project_id, anchor.run_id, anchor.generation]))
194
+ appendEvent(context, controller.controller_id, "lifecycle_outcome", record);
195
+ });
196
+ }
197
+ catch (persistence) {
198
+ if (persistence instanceof AppError && persistence.code === "lifecycle_outcome_conflict")
199
+ throw persistence;
200
+ // Diagnostic evidence is not replay authority. If SQL is unavailable,
201
+ // the controller still sees this failure and fences instead of waiting
202
+ // for a model to report it or invent a new invocation.
203
+ const directory = lifecycleDiagnostics(context, anchor.project_id, anchor.run_id, anchor.generation);
204
+ try {
205
+ fs.mkdirSync(directory, { recursive: true });
206
+ fs.writeFileSync(path.join(directory, `${anchor.hook_event_id}.json`), JSON.stringify({ ...record, disposition: "fatal", persistence_error: String(persistence) }), { flag: "wx", mode: 0o600, flush: true });
207
+ }
208
+ catch (diagnostic) {
209
+ if (diagnostic.code !== "EEXIST")
210
+ process.stderr.write(`${JSON.stringify({ primary: record.error ?? { effect: "committed" }, persistence: String(persistence), diagnostic: String(diagnostic) })}\n`);
211
+ }
212
+ throw persistence;
213
+ }
214
+ };
215
+ }
216
+ function lifecycleDiagnostics(context, projectId, runId, generation) {
217
+ return path.join(context.ddFlowHome, "diagnostics", "lifecycle", encodeURIComponent(projectId), encodeURIComponent(runId), String(generation));
218
+ }
55
219
  /** Current, explicitly-issued replacements for commands from the resumed stage.
56
220
  * Original prompts remain immutable evidence; callers may present this small
57
221
  * overlay after a state transition made an earlier no-effect rejection retryable. */
@@ -120,25 +284,15 @@ export function managedLifecycleCommand(context, command) {
120
284
  return renderInvocationCommand(retained, command);
121
285
  }
122
286
  storage(context);
123
- context.db.exec("SAVEPOINT invocation_command");
124
- try {
287
+ return context.db.writeTransaction(() => {
125
288
  const prior = context.db.get("SELECT * FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? ORDER BY rowid DESC LIMIT 1", [JSON.stringify(scope), fingerprint(parsed)]);
126
289
  if (prior && prior.id !== context.env.DD_FLOW_CURRENT_INVOCATION) {
127
- const rendered = renderInvocationCommand(prior, command);
128
- context.db.exec("RELEASE invocation_command");
129
- return rendered;
290
+ return renderInvocationCommand(prior, command);
130
291
  }
131
292
  if (context.env.DD_FLOW_INVOCATION_READONLY === "1")
132
293
  throw new AppError("invocation_command_unprepared", "No prepared lifecycle command is available; read-only inspection cannot issue one", 1);
133
- const prepared = issueLifecycleInvocation(context, command, scope);
134
- context.db.exec("RELEASE invocation_command");
135
- return prepared.command;
136
- }
137
- catch (error) {
138
- context.db.exec("ROLLBACK TO invocation_command");
139
- context.db.exec("RELEASE invocation_command");
140
- throw error;
141
- }
294
+ return issueLifecycleInvocation(context, command, scope).command;
295
+ });
142
296
  }
143
297
  export function retryLifecycleInvocationCommand(context, id) {
144
298
  const prior = load(context, id);
@@ -170,7 +324,7 @@ function successorLifecycleInvocationCommand(context, prior) {
170
324
  * callers must never time out merely because the observer rejected syntax. */
171
325
  export function settleLifecycleNoEffectRejection(context, id, command, error, identity) {
172
326
  storage(context);
173
- context.db.exec("BEGIN IMMEDIATE");
327
+ context.db.beginWriteTransaction();
174
328
  try {
175
329
  const prior = load(context, id);
176
330
  checkCommand(prior, command);
@@ -211,14 +365,18 @@ const receiptTimeout = (id) => ({ code: "invocation_receipt_timeout", message: "
211
365
  export function settleLifecycleRejection(context, id, error) {
212
366
  storage(context);
213
367
  const details = { ...error.details };
214
- context.db.exec("BEGIN IMMEDIATE");
368
+ context.db.beginWriteTransaction();
215
369
  try {
216
370
  const prior = load(context, id);
217
371
  if (prior.status !== "executing")
218
372
  throw new AppError("invocation_settlement_conflict", "Attempt is no longer executing", 1, { invocation_id: id });
219
- if (isRetryableLifecycleRejection(error.code, error.details)) {
373
+ const storageRetry = error.code === "storage_write_failed" && error.details.effect === "no_effect"
374
+ && !context.db.get("SELECT 1 FROM lifecycle_invocations WHERE scope_json = ? AND fingerprint = ? AND json_extract(outcome_json, '$.error.details.storage_retry') = 1 LIMIT 1", [prior.scope_json, prior.fingerprint]);
375
+ if (isRetryableLifecycleRejection(error.code, error.details) || storageRetry) {
220
376
  const retry = retryLifecycleInvocationCommand(context, id);
221
377
  details.retry_command = retry;
378
+ if (storageRetry)
379
+ details.storage_retry = 1;
222
380
  details.retry_instruction = "Correct only the evidenced defect, then use retry_command with the required result input. Reissuing the old command only reads its retained outcome.";
223
381
  }
224
382
  settleLifecycleInvocation(context, id, { error: { ...error, details } });
@@ -339,6 +497,9 @@ function commandWithInvocationId(command, id) {
339
497
  * Preserve current stdin/heredoc/response-file text while validating that it
340
498
  * still names the exact persisted operation and project. */
341
499
  function assertRenderableInvocation(row) {
500
+ const outcome = row.outcome_json ? JSON.parse(row.outcome_json) : null;
501
+ if (row.status === "settled" && outcome?.error)
502
+ throw new AppError("invocation_command_blocked", "Failed lifecycle authority cannot be advertised as an executable command", 1, { invocation_id: row.id, outcome });
342
503
  if (row.status === "expired" || row.status === "issued" && row.deadline !== null && row.deadline <= Date.now()) {
343
504
  throw new AppError("invocation_expired", "Retained lifecycle attempt has expired; inspection cannot grant a new attempt", 1, { invocation_id: row.id, status: row.status, outcome: row.outcome_json ? JSON.parse(row.outcome_json) : null });
344
505
  }
@@ -373,7 +534,7 @@ export function observeLifecycleInvocation(context, input) {
373
534
  const identity = input.identity;
374
535
  checkNativeIdentity(scope, identity, input.id);
375
536
  const identityJson = JSON.stringify([identity.daemonId, identity.rootSessionId, identity.sessionId, identity.parentSessionId, identity.toolCallId]);
376
- context.db.exec("BEGIN IMMEDIATE");
537
+ context.db.beginWriteTransaction();
377
538
  try {
378
539
  const row = context.db.get("SELECT * FROM lifecycle_invocations WHERE id = ?", [input.id]);
379
540
  if (row.status === "settled") {
@@ -420,8 +581,19 @@ export async function awaitLifecycleInvocation(context, input) {
420
581
  const row = load(context, input.id);
421
582
  if (row.status === "settled")
422
583
  return { outcome: JSON.parse(row.outcome_json), replay: true };
423
- if (row.status === "executing")
424
- throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id });
584
+ if (row.status === "executing") {
585
+ const scope = JSON.parse(row.scope_json);
586
+ if (invocation(row.command).operation === "work_start" && row.event_key) {
587
+ input.assertCurrent(scope);
588
+ const result = recoverCommittedWorkStart(context, row.id, row.event_key, scope.generation);
589
+ if (result) {
590
+ const outcome = { result, effect: "committed" };
591
+ settleLifecycleInvocation(context, row.id, outcome);
592
+ return { outcome, replay: true };
593
+ }
594
+ }
595
+ throw new AppError("invocation_outcome_pending", "This attempt already started; inspect its retained operation outcome before retrying", 1, { invocation_id: input.id, effect: "unknown" });
596
+ }
425
597
  input.assertCurrent(JSON.parse(row.scope_json));
426
598
  if (row.status === "observed") {
427
599
  const claimed = context.db.run("UPDATE lifecycle_invocations SET status = 'executing', updated_at = ? WHERE id = ? AND status = 'observed'", [context.now(), input.id]);
@@ -430,7 +602,7 @@ export async function awaitLifecycleInvocation(context, input) {
430
602
  continue;
431
603
  }
432
604
  if (row.status === "expired" || (row.deadline !== null && row.deadline <= Date.now())) {
433
- context.db.exec("BEGIN IMMEDIATE");
605
+ context.db.beginWriteTransaction();
434
606
  try {
435
607
  const current = load(context, input.id);
436
608
  if (["issued", "expired"].includes(current.status)) {
@@ -47,7 +47,7 @@ export function registerManagedProcess(context, input) {
47
47
  if (!input.operationId)
48
48
  throw new Error("Unique managed operation requires operationId");
49
49
  const db = registry(context);
50
- db.exec("BEGIN IMMEDIATE");
50
+ db.beginWriteTransaction();
51
51
  try {
52
52
  const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
53
53
  if (existing)
@@ -154,7 +154,7 @@ export function managedOwnerIsAlive(record) {
154
154
  export function claimExpiredManagedProcesses(context, ownerId) {
155
155
  const db = registry(context);
156
156
  const now = context.now();
157
- db.exec("BEGIN IMMEDIATE");
157
+ db.beginWriteTransaction();
158
158
  try {
159
159
  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]);
160
160
  const claimed = [];
@@ -36,7 +36,7 @@ export function claimNextMergeJob(context, input) {
36
36
  });
37
37
  const now = context.now();
38
38
  let claimedProtocolId = null;
39
- context.db.exec("BEGIN IMMEDIATE");
39
+ context.db.beginWriteTransaction();
40
40
  try {
41
41
  const job = context.db.get(`SELECT * FROM merge_queue
42
42
  WHERE project_id = ? AND status IN ('ready', 'requeued')
@@ -90,7 +90,7 @@ export function claimMergeJob(context, input) {
90
90
  workspacePath: input.workspacePath
91
91
  });
92
92
  const now = context.now();
93
- context.db.exec("BEGIN IMMEDIATE");
93
+ context.db.beginWriteTransaction();
94
94
  try {
95
95
  const job = queueJobByProtocol(context, project.id, input.protocolId);
96
96
  if (!job || !["ready", "requeued"].includes(job.status)) {
@@ -141,7 +141,7 @@ export function claimMergeBundle(context, input) {
141
141
  workspacePath: input.workspacePath
142
142
  });
143
143
  const now = context.now();
144
- context.db.exec("BEGIN IMMEDIATE");
144
+ context.db.beginWriteTransaction();
145
145
  try {
146
146
  for (const protocolId of protocolIds) {
147
147
  const update = context.db.run(`UPDATE merge_queue
@@ -381,7 +381,7 @@ export function completeMergeBundle(context, input) {
381
381
  });
382
382
  }
383
383
  const now = context.now();
384
- context.db.exec("BEGIN IMMEDIATE");
384
+ context.db.beginWriteTransaction();
385
385
  try {
386
386
  for (const protocolId of protocolIds) {
387
387
  const protocol = requireProtocol(context, protocolId, project.id);
@@ -517,7 +517,7 @@ export function failMergeBundle(context, input) {
517
517
  }
518
518
  const now = context.now();
519
519
  const status = input.requeue ? "requeued" : "failed";
520
- context.db.exec("BEGIN IMMEDIATE");
520
+ context.db.beginWriteTransaction();
521
521
  try {
522
522
  for (const protocolId of protocolIds) {
523
523
  const protocol = requireProtocol(context, protocolId, project.id);
@@ -574,7 +574,7 @@ export function cancelMergeQueueJob(context, input) {
574
574
  throw new AppError("validation", "merge-queue cancel requires --reason", 2);
575
575
  }
576
576
  const now = context.now();
577
- context.db.exec("BEGIN IMMEDIATE");
577
+ context.db.beginWriteTransaction();
578
578
  try {
579
579
  const job = queueJobByProtocol(context, protocol.project_id, protocol.id);
580
580
  if (!job) {
@@ -63,7 +63,7 @@ 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.exec("BEGIN IMMEDIATE");
66
+ context.db.beginWriteTransaction();
67
67
  try {
68
68
  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
69
  if (!request)
@@ -217,7 +217,7 @@ export function applyMigration(context, input) {
217
217
  chain: chain.steps.map((step) => ({ ...step, status: "applied" }))
218
218
  };
219
219
  try {
220
- writeDb.exec("BEGIN IMMEDIATE");
220
+ writeDb.beginWriteTransaction();
221
221
  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()]);
222
222
  writeDb.exec("COMMIT");
223
223
  }
@@ -140,7 +140,7 @@ export function migrateProjectIds(context, input) {
140
140
  throw new AppError("conflict", `Target project id already exists: ${target.id}`, 1, plan);
141
141
  }
142
142
  context.db.exec("PRAGMA foreign_keys = OFF");
143
- context.db.exec("BEGIN IMMEDIATE");
143
+ context.db.beginWriteTransaction();
144
144
  try {
145
145
  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]);
146
146
  for (const table of projectReferenceTables()) {
@@ -416,7 +416,7 @@ export function syncProtocolFromRun(context, input) {
416
416
  next_action: nextActionForSyncedTarget(targetStage),
417
417
  updated_at: now
418
418
  };
419
- context.db.exec("BEGIN IMMEDIATE");
419
+ context.db.beginWriteTransaction();
420
420
  try {
421
421
  persistProtocolState(context, protocol, nextState);
422
422
  if (targetStage === "ready_for_merge") {
@@ -486,7 +486,7 @@ export function cancelProtocol(context, input) {
486
486
  const now = context.now();
487
487
  const worktree = context.db.get("SELECT worktree_path, status FROM worktree_records WHERE project_id = ? AND protocol_id = ?", [protocol.project_id, protocol.id]);
488
488
  const worktreeRemoval = input.worktree === "remove" ? removeProtocolWorktreeBeforeCancel(protocol, state, worktree, input.force) : undefined;
489
- context.db.exec("BEGIN IMMEDIATE");
489
+ context.db.beginWriteTransaction();
490
490
  try {
491
491
  const nextState = {
492
492
  ...state,
@@ -57,7 +57,7 @@ async function launchControlWorker(context, input) {
57
57
  // Reclaim only a proven dead launcher or admitted worker. Lease expiry
58
58
  // never proves death or authorizes a second physical owner.
59
59
  let claimed = false;
60
- context.db.exec("BEGIN IMMEDIATE");
60
+ context.db.beginWriteTransaction();
61
61
  try {
62
62
  claimed = context.db.run("INSERT OR IGNORE INTO run_control_workers (control_id, owner_token, status, resource_home, state_dir, created_at, updated_at) VALUES (?, ?, 'starting', ?, ?, ?, ?)", [input.controlId, token, home, directory, now, now]).changes === 1;
63
63
  if (claimed) {
@@ -163,7 +163,7 @@ function reclaimControlWorker(context, input, projectId, token, home) {
163
163
  // and a crash before the RUN CAS leaves the same dead owner reconcilable.
164
164
  if (record && !finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: "control_owner_crash_reconciled" }))
165
165
  return false;
166
- context.db.exec("BEGIN IMMEDIATE");
166
+ context.db.beginWriteTransaction();
167
167
  try {
168
168
  const changed = context.db.run(`UPDATE run_control_workers SET owner_token = ?, owner_pid = NULL, status = 'starting', process_id = NULL, process_lease_token = NULL, updated_at = ?
169
169
  WHERE control_id = ? AND owner_token = ? AND process_id IS ? AND owner_pid IS ? AND status = ?
@@ -202,7 +202,7 @@ export async function serveRunControl(context, input) {
202
202
  throw new AppError("run_control_owner_mismatch", "Control registration is not authorized", 1);
203
203
  // Physical identity is committed with the sole RUN claim, before the worker
204
204
  // registers itself in the separate resource store or performs any dispatch.
205
- context.db.exec("BEGIN IMMEDIATE");
205
+ context.db.beginWriteTransaction();
206
206
  try {
207
207
  const claimed = context.db.run("UPDATE run_control_workers SET status = 'running', owner_pid = ?, updated_at = ? WHERE control_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [process.pid, context.now(), input.controlId, worker.owner_token]);
208
208
  if (claimed.changes !== 1)
@@ -413,10 +413,29 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
413
413
  }
414
414
  unresolved.push({ check_id: check.id, status: "pending", reason });
415
415
  }
416
+ const uncreatedDirectories = new Map();
416
417
  const sessionNodes = Promise.all([...targets.values()].map(async ({ session, executable, harness }) => {
417
418
  const env = { ...context.env, DD_FLOW_HOME: context.ddFlowHome };
418
419
  delete env.DD_FLOW_CONTROLLER_TOKEN;
419
420
  delete env.DD_FLOW_CONTROL_TOKEN;
421
+ // An explicit pre-registration rejection proves there is no Session to
422
+ // cancel. A missing id or an arbitrary startup failure does not.
423
+ if (!session.id && !session.create_operation_id && !daemonInventories.has(session.state_dir) && !unsafeDaemons.has(canonicalPath(session.state_dir, false)) && !fs.existsSync(path.join(session.state_dir, "daemon.json"))) {
424
+ const operations = context.db.all("SELECT o.operation_id, o.input_json FROM run_controller_operations o JOIN run_controllers c ON c.controller_id = o.controller_id WHERE c.project_id = ? AND c.run_id = ? AND json_extract(o.input_json, '$.state_dir') = ?", [project.id, input.runId, session.state_dir]);
425
+ if (operations.length === 1)
426
+ try {
427
+ const operation = operations[0];
428
+ const saved = JSON.parse(operation.input_json);
429
+ const journal = retainedDaemonOperations(session.state_dir);
430
+ const receipt = journal.length === 1 ? journal[0] : null;
431
+ const error = receipt?.error;
432
+ if (saved.executable === executable && saved.owner === session.owner.owner_id && saved.args?.[0] === "daemon" && saved.args?.[1] === "start" && receipt?.operation_id === operation.operation_id && receipt.operation === "daemon.start" && receipt.predecessor_daemon_id === null && receipt.state === "failed" && error?.details?.startup_resources === "not_created" && !registeredProcesses.some(({ record }) => JSON.parse(record.metadata_json).state_dir === session.state_dir)) {
433
+ uncreatedDirectories.set(session.state_dir, JSON.stringify(journal));
434
+ return { state_dir: session.state_dir, status: "completed", receipt: { source: "startup_resources_not_created", operation_id: operation.operation_id, error } };
435
+ }
436
+ }
437
+ catch { /* Incomplete evidence retains the normal unknown outcome. */ }
438
+ }
420
439
  if (!session.id && session.create_operation_id) {
421
440
  try {
422
441
  assertCurrent();
@@ -622,7 +641,7 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
622
641
  };
623
642
  bindings = readBindings();
624
643
  for (const target of targets.values())
625
- if (!daemonInventories.has(target.session.state_dir))
644
+ if (!daemonInventories.has(target.session.state_dir) && !uncreatedDirectories.has(target.session.state_dir))
626
645
  unresolved.push({ state_dir: target.session.state_dir, status: "pending", reason: "runtime_inventory_unverified" });
627
646
  for (const node of nodes)
628
647
  if ("harness" in node && "session_id" in node && typeof node.session_id === "string" && bindings.some(binding => foreign(binding) && underRoot(binding, node.session_id, node.harness ?? null)))
@@ -647,6 +666,9 @@ async function reconcileOwnedSessions(context, input, interrupt, boundary) {
647
666
  const processVersion = (items) => JSON.stringify(items.map(({ home, record }) => [home, record.id, record.kind, record.owner_id, record.project_id, record.run_id, record.work_id, record.check_id, record.operation_id, record.pid, record.pid_started_at, record.lease_token, record.state, record.metadata_json]).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))));
648
667
  verifyInventory = () => {
649
668
  assertCurrent();
669
+ for (const [directory, journal] of uncreatedDirectories)
670
+ if (fs.existsSync(path.join(directory, "daemon.json")) || JSON.stringify(retainedDaemonOperations(directory)) !== journal)
671
+ throw new AppError("owned_inventory_changed", "Rejected startup acquired new runtime evidence", 1);
650
672
  const currentProcesses = [...resourceHomes].flatMap(home => managedProcessStatus({ ...context, env: { ...context.env, DD_FLOW_RESOURCE_HOME: home } }).filter(record => record.project_id === project.id && record.run_id === input.runId && [null, context.ddFlowHome].includes(managedProcessRuntimeHome(record, context))).map(record => ({ home, record })));
651
673
  if (initialDatabaseVersion !== databaseVersion() || processVersion(registeredProcesses) !== processVersion(currentProcesses) || [...nativeVersions].some(([directory, version]) => version !== nativeInventoryVersion(directory)) || [...importVersions].some(([file, version]) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== version))
652
674
  throw new AppError("owned_inventory_changed", "Owned writers changed after their settlement observation", 1);
@@ -726,7 +748,7 @@ export function recordRunControlIntent(context, input) {
726
748
  const request = { project_root: project.root, run_id: input.runId, action: input.action, ...(input.action === "pause" ? { grace_ms: input.graceMs ?? 30_000, escalate: input.escalate ?? null } : { force: input.force ?? false }) };
727
749
  const requestHash = crypto.createHash("sha256").update(JSON.stringify(request)).digest("hex");
728
750
  ensureRunControlStorage(context.db);
729
- context.db.exec("BEGIN IMMEDIATE");
751
+ context.db.beginWriteTransaction();
730
752
  try {
731
753
  const existing = context.db.get("SELECT * FROM run_controls WHERE project_id = ? AND run_id = ? AND request_id = ?", [project.id, input.runId, input.requestId]);
732
754
  if (existing) {
@@ -50,7 +50,7 @@ export function controllerEnvironment(context, manifest, session) {
50
50
  env.CODEX_HOME = session.codex_home;
51
51
  return env;
52
52
  }
53
- export async function controllerAdapter(context, row, manifest, session, args, assertOwner, retainedOperationId) {
53
+ export async function controllerAdapter(context, row, manifest, session, args, assertOwner, retainedOperationId, signal) {
54
54
  assertOwner?.();
55
55
  const operationId = retainedOperationId ?? `${row.controller_id}:${crypto.randomUUID()}`;
56
56
  const executable = manifest.adapters[runtimeHarness(session.profile)].executable;
@@ -60,7 +60,7 @@ export async function controllerAdapter(context, row, manifest, session, args, a
60
60
  appendEvent(context, row.controller_id, "operation_requested", { operation_id: operationId, operation: args.slice(0, 2), session_id: session.id || null });
61
61
  try {
62
62
  assertOwner?.();
63
- const receipt = await runHarnessAdapter({ executable, args, env: controllerEnvironment(context, manifest, session), operationId, runtimeOwner: session.owner, timeoutMs: args[0] === "session" && args[1] === "prompt" ? 2 * 60 * 60_000 : 120_000 });
63
+ const receipt = await runHarnessAdapter({ executable, args, env: controllerEnvironment(context, manifest, session), operationId, runtimeOwner: session.owner, timeoutMs: args[0] === "session" && args[1] === "prompt" ? 2 * 60 * 60_000 : 120_000, ...(signal ? { signal } : {}) });
64
64
  context.db.run("UPDATE run_controller_operations SET receipt_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
65
65
  const target = args[args.indexOf("--session-id") + 1];
66
66
  if (args[0] === "session" && args.includes("--session-id") && adapterSessionId(receipt) !== target)
@@ -73,7 +73,12 @@ export async function controllerAdapter(context, row, manifest, session, args, a
73
73
  return receipt;
74
74
  }
75
75
  catch (error) {
76
- context.db.run("UPDATE run_controller_operations SET status = CASE WHEN status = 'dispatching' THEN 'outcome_unknown' ELSE status END, error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(errorRecord(error)), context.now(), operationId]);
76
+ try {
77
+ context.db.run("UPDATE run_controller_operations SET status = CASE WHEN status = 'dispatching' THEN 'outcome_unknown' ELSE status END, error_json = ?, updated_at = ? WHERE operation_id = ?", [JSON.stringify(errorRecord(error)), context.now(), operationId]);
78
+ }
79
+ catch (persistence) {
80
+ process.stderr.write(`${JSON.stringify({ operation_id: operationId, primary: errorRecord(error), persistence_error: errorRecord(persistence) })}\n`);
81
+ }
77
82
  throw error;
78
83
  }
79
84
  }
@@ -35,7 +35,7 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
35
35
  throw error;
36
36
  }
37
37
  }
38
- context.db.exec("BEGIN IMMEDIATE");
38
+ context.db.beginWriteTransaction();
39
39
  try {
40
40
  assertPhysicalOwner();
41
41
  assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation);
@@ -112,7 +112,7 @@ export async function captureControllerBoundary(context, row, manifest, stage, n
112
112
  await delay(observationBudget.nextDelay());
113
113
  }
114
114
  }
115
- context.db.exec("BEGIN IMMEDIATE");
115
+ context.db.beginWriteTransaction();
116
116
  try {
117
117
  assertCurrent();
118
118
  context.db.run("UPDATE run_controller_operations SET status = 'completed', receipt_json = ?, error_json = NULL, updated_at = ? WHERE operation_id = ?", [JSON.stringify(receipt), context.now(), operationId]);
@@ -92,7 +92,7 @@ function prepareSpawn(context, row) {
92
92
  return null;
93
93
  if (launch.value.retired_process)
94
94
  retire(resourceContext, row, launch.value.retired_process);
95
- context.db.exec("BEGIN IMMEDIATE");
95
+ context.db.beginWriteTransaction();
96
96
  try {
97
97
  assertLaunchAllowed(context, row);
98
98
  const changed = context.db.run(`UPDATE run_controllers SET owner_token = ?, owner_pid = NULL, process_id = NULL, process_lease_token = NULL, status = 'starting', last_error_json = NULL, updated_at = ?
@@ -121,7 +121,7 @@ export function claimControllerProcess(context, row) {
121
121
  const launch = launchRecord(context, row);
122
122
  if (!launch && (!row.process_id || !row.process_lease_token))
123
123
  throw new AppError("controller_owner_mismatch", "Managed registration is not authorized", 1);
124
- context.db.exec("BEGIN IMMEDIATE");
124
+ context.db.beginWriteTransaction();
125
125
  try {
126
126
  const claimed = context.db.run("UPDATE run_controllers SET owner_pid = ?, status = 'running', updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status = 'starting' AND owner_pid IS NULL", [process.pid, context.now(), row.controller_id, row.owner_token]);
127
127
  if (claimed.changes !== 1)
@@ -78,7 +78,7 @@ export function recordControllerResume(context, input) {
78
78
  if (!previousOwner || previousOwner.lease_token !== row.process_lease_token || previousOwner.kind !== "run-controller" || previousOwner.owner_id !== row.controller_id || previousOwner.project_id !== project.id || previousOwner.run_id !== run.id || managedProcessRuntimeHome(previousOwner, context) !== context.ddFlowHome || !previousOwner.pid || !previousOwner.pid_started_at || processTreeIsAlive(previousOwner))
79
79
  throw new AppError("controller_owner_unsettled", "Resume requires proof that the previous physical controller has exited", 1);
80
80
  const token = crypto.randomUUID(), now = context.now();
81
- context.db.exec("BEGIN IMMEDIATE");
81
+ context.db.beginWriteTransaction();
82
82
  try {
83
83
  const duplicate = context.db.get("SELECT input_hash, status FROM run_controller_operations WHERE operation_id = ?", [operationId]);
84
84
  if (duplicate) {
@@ -168,7 +168,7 @@ export async function resumeControllerSession(context, row, manifest, state, ass
168
168
  throw new AppError("recovery_session_unsettled", "Retained native Session is not ready for same-Session recovery", 1);
169
169
  assertRecoveryOwner();
170
170
  const prepared = { prepared: true, recovery_id: recovery.recovery_id, session_id: session.id, daemon_id: daemonId, process_id: dispatch.process_id, resume_operation_id: dispatch.resume_operation_id };
171
- context.db.exec("BEGIN IMMEDIATE");
171
+ context.db.beginWriteTransaction();
172
172
  try {
173
173
  assertRecoveryOwner();
174
174
  const binding = requireManagedDaemonBinding(executionContext, { stateDir: session.state_dir, projectId: row.project_id, runId: row.run_id, ownerId: session.owner.owner_id });
@@ -195,7 +195,7 @@ export async function resumeControllerSession(context, row, manifest, state, ass
195
195
  assertRecoveryOwner();
196
196
  if (recoveryGuard(context, row.project_id, row.run_id)?.status !== "recovered")
197
197
  throw new AppError("trusted_recovery_acceptance_required", "Recovery Turn ended without its native acknowledgement", 1);
198
- context.db.exec("BEGIN IMMEDIATE");
198
+ context.db.beginWriteTransaction();
199
199
  try {
200
200
  assertRecoveryOwner();
201
201
  context.db.run("UPDATE run_controls SET status = 'resumed', updated_at = ? WHERE control_id = ? AND status = 'resuming'", [context.now(), recovery.control_id]);