@deksden-com/dd-flow-cli 0.9.0-beta.7 → 0.9.0-beta.75

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 (123) hide show
  1. package/CHANGELOG.md +404 -0
  2. package/README.md +65 -0
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/help.js +81 -9
  5. package/dist/cli/run-cli.js +442 -46
  6. package/dist/harness-runtime/bin/dd-agy.mjs +37 -0
  7. package/dist/harness-runtime/bin/dd-codex.mjs +25 -0
  8. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  9. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  10. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  11. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  12. package/dist/harness-runtime/lib/daemon-operations.mjs +215 -0
  13. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +423 -0
  14. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  15. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +141 -0
  16. package/dist/harness-runtime/lib/dd-codex.mjs +438 -0
  17. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  18. package/dist/harness-runtime/lib/dd-droid.mjs +471 -0
  19. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +274 -0
  20. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  21. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +198 -0
  22. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  23. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +644 -0
  24. package/dist/harness-runtime/lib/dd-zcode.mjs +910 -0
  25. package/dist/harness-runtime/lib/delegation-instructions.d.mts +10 -0
  26. package/dist/harness-runtime/lib/delegation-instructions.mjs +121 -0
  27. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  28. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  29. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  30. package/dist/harness-runtime/lib/managed-daemon.mjs +260 -0
  31. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  32. package/dist/harness-runtime/lib/native-hook-command.mjs +34 -0
  33. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  34. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  35. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  36. package/dist/harness-runtime/lib/process-json.mjs +70 -0
  37. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  38. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  39. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  40. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  41. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  42. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  43. package/dist/runtime/context.js +3 -1
  44. package/dist/schemas/agent-profile.schema.json +1 -1
  45. package/dist/schemas/code-review-result.schema.json +2 -2
  46. package/dist/schemas/code-work-batch.schema.json +4 -3
  47. package/dist/schemas/code-work-result.schema.json +4 -4
  48. package/dist/schemas/harness-config.schema.json +23 -0
  49. package/dist/schemas/plan-review-result.schema.json +2 -2
  50. package/dist/schemas/run-control-receipt.schema.json +99 -0
  51. package/dist/schemas/run-control-request.schema.json +36 -0
  52. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  53. package/dist/services/cleanup.js +8 -5
  54. package/dist/services/cli-operation-classifier.js +11 -1
  55. package/dist/services/code-checks.js +543 -96
  56. package/dist/services/controller-fanout.js +116 -0
  57. package/dist/services/dashboard.js +9 -0
  58. package/dist/services/engines.js +50 -19
  59. package/dist/services/eval-snapshots.js +639 -60
  60. package/dist/services/execution-policy.js +177 -0
  61. package/dist/services/external-work-launch.js +110 -0
  62. package/dist/services/harness-adapter.js +147 -24
  63. package/dist/services/harness-config.js +68 -0
  64. package/dist/services/hooks.js +413 -168
  65. package/dist/services/lanes.js +1 -1
  66. package/dist/services/lifecycle-command.js +74 -6
  67. package/dist/services/lifecycle-invocations.js +637 -0
  68. package/dist/services/managed-daemon-binding.js +38 -0
  69. package/dist/services/managed-processes.js +138 -22
  70. package/dist/services/merge-queue.js +6 -6
  71. package/dist/services/merge-server.js +75 -29
  72. package/dist/services/migrations.js +1 -1
  73. package/dist/services/native-daemon-history.js +49 -0
  74. package/dist/services/native-session-control.js +39 -0
  75. package/dist/services/projects.js +7 -3
  76. package/dist/services/prompts.js +4 -2
  77. package/dist/services/protocols.js +11 -2
  78. package/dist/services/recovery-observation-budget.js +61 -0
  79. package/dist/services/recovery-snapshot-database.js +107 -0
  80. package/dist/services/run-control-receipt.js +56 -0
  81. package/dist/services/run-control-worker.js +363 -0
  82. package/dist/services/run-control.js +876 -0
  83. package/dist/services/run-controller-adapter.js +211 -0
  84. package/dist/services/run-controller-capture.js +134 -0
  85. package/dist/services/run-controller-process.js +189 -0
  86. package/dist/services/run-controller-recovery.js +222 -0
  87. package/dist/services/run-controller-state.js +31 -0
  88. package/dist/services/run-controller.js +733 -0
  89. package/dist/services/run-engine-bindings.js +20 -62
  90. package/dist/services/run-fork.js +124 -0
  91. package/dist/services/run-observations.js +112 -0
  92. package/dist/services/run-recovery-runtime.js +69 -0
  93. package/dist/services/run-recovery.js +327 -0
  94. package/dist/services/runs.js +280 -40
  95. package/dist/services/runtime-budget.js +362 -0
  96. package/dist/services/runtime-scope-capture.js +50 -0
  97. package/dist/services/runtime-scope-control.js +450 -0
  98. package/dist/services/runtime-scope-resume.js +543 -0
  99. package/dist/services/runtime-scope-stop.js +99 -0
  100. package/dist/services/runtime-scope-worker.js +210 -0
  101. package/dist/services/runtime-service.js +96 -0
  102. package/dist/services/schema-validation.js +12 -12
  103. package/dist/services/stage-context.js +2 -2
  104. package/dist/services/stage-lifecycle.js +32 -10
  105. package/dist/services/stage-pause.js +25 -13
  106. package/dist/services/usage.js +37 -93
  107. package/dist/services/vnext-code-review.js +149 -89
  108. package/dist/services/vnext-code.js +224 -216
  109. package/dist/services/vnext-execution-profile.js +5 -3
  110. package/dist/services/vnext-fanout.js +135 -21
  111. package/dist/services/vnext-merge.js +258 -89
  112. package/dist/services/vnext-plan-review.js +75 -64
  113. package/dist/services/vnext-plan.js +77 -29
  114. package/dist/services/vnext-protocolize.js +21 -14
  115. package/dist/services/vnext-specify.js +26 -16
  116. package/dist/services/work-registry.js +605 -157
  117. package/dist/services/workspace-bootstrap.js +76 -0
  118. package/dist/shared/errors.js +12 -0
  119. package/dist/storage/database.js +341 -31
  120. package/dist/storage/writer-contract.js +77 -0
  121. package/dist/storage/writer-migration.js +102 -0
  122. package/package.json +20 -13
  123. package/tools/repair-paused-run-status.mjs +59 -0
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { AppError } from "../shared/errors.js";
4
+ import { managedProcessRuntimeHome, managedProcessStatus } from "./managed-processes.js";
5
+ export function readNativeDaemonState(stateDir) {
6
+ if (!path.isAbsolute(stateDir))
7
+ throw new AppError("daemon_owner_mismatch", "Native state directory must be absolute", 1);
8
+ return JSON.parse(fs.readFileSync(path.join(stateDir, "daemon.json"), "utf8"));
9
+ }
10
+ /** A shared directory or logical owner is not a physical provider binding. */
11
+ export function managedProviderBelongsToDaemon(provider, daemon, state) {
12
+ const child = JSON.parse(provider.metadata_json), parent = JSON.parse(daemon.metadata_json);
13
+ return provider.kind === daemon.kind.replace(/-daemon$/, "-provider")
14
+ && state.provider_process?.id === provider.id && state.provider_process.lease_token === provider.lease_token
15
+ && Boolean(provider.pid && provider.pid_started_at && daemon.pid && daemon.pid_started_at)
16
+ && state.provider_pid === provider.pid && child.owner_pid === daemon.pid && child.owner_pid_started_at === daemon.pid_started_at
17
+ && provider.owner_id === daemon.owner_id && provider.project_id === daemon.project_id && provider.run_id === daemon.run_id && provider.work_id === daemon.work_id
18
+ && child.state_dir === parent.state_dir && child.role === parent.role && child.dd_flow_home === parent.dd_flow_home && child.adapter_executable === parent.adapter_executable
19
+ && child.dispatch_barrier === "run-generation@1" && child.budget?.scope_id === parent.budget?.scope_id;
20
+ }
21
+ /** Identity proof only. Liveness and clean shutdown are separate caller decisions. */
22
+ export function requireManagedDaemonBinding(context, input) {
23
+ const state = readNativeDaemonState(input.stateDir);
24
+ const owner = state.config?.runtime_owner;
25
+ const record = managedProcessStatus(context).find(record => record.id === state.resource_process?.id);
26
+ const metadata = record && JSON.parse(record.metadata_json);
27
+ if (!record || !owner || !record.kind.endsWith("-daemon")
28
+ || record.lease_token !== state.resource_process?.lease_token
29
+ || !record.pid || record.pid !== state.pid || !record.pid_started_at
30
+ || record.owner_id !== owner.owner_id || input.ownerId !== undefined && record.owner_id !== input.ownerId
31
+ || record.project_id !== input.projectId || owner.project_id !== input.projectId
32
+ || record.run_id !== input.runId || owner.run_id !== input.runId
33
+ || metadata?.state_dir !== input.stateDir || owner.state_dir !== input.stateDir
34
+ || owner.dd_flow_home !== context.ddFlowHome || managedProcessRuntimeHome(record, context) !== context.ddFlowHome) {
35
+ throw new AppError("daemon_owner_mismatch", "Native daemon has no proven physical ownership in this RUN and runtime home", 1);
36
+ }
37
+ return { state, process: record };
38
+ }
@@ -1,19 +1,79 @@
1
1
  import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import { spawnSync } from "node:child_process";
3
5
  import net from "node:net";
4
6
  import { getResourceDatabase } from "../storage/database.js";
7
+ import { AppError } from "../shared/errors.js";
8
+ export function managedProcessBinding(record, home) {
9
+ return { resource_home: home, process_id: record.id, registration_sha256: crypto.createHash("sha256").update(JSON.stringify([home, record.id, record.kind, record.owner_id, record.lease_token, record.pid, record.pid_started_at, record.project_id, record.run_id, record.work_id, record.check_id, record.operation_id, record.metadata_json])).digest("hex") };
10
+ }
5
11
  const defaultLeaseMs = 15 * 60_000;
6
12
  export function resourceHome(context) {
7
13
  return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
8
14
  }
15
+ /** Runtime IDs are home-local; a shared host registry needs this extra scope. */
16
+ export function managedProcessRuntimeHome(record, authority) {
17
+ const metadata = parseMetadata(record.metadata_json);
18
+ if (metadata.dd_flow_home !== undefined)
19
+ return typeof metadata.dd_flow_home === "string" && path.isAbsolute(metadata.dd_flow_home) ? metadata.dd_flow_home : null;
20
+ // Older native registrations can still prove their home from the retained
21
+ // exact daemon owner; PID or project/RUN labels alone never establish it.
22
+ if (typeof metadata.state_dir === "string" && path.isAbsolute(metadata.state_dir)) {
23
+ try {
24
+ const state = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
25
+ const owner = state.config?.runtime_owner;
26
+ if (owner?.owner_id === record.owner_id && owner.project_id === record.project_id && owner.run_id === record.run_id && owner.state_dir === metadata.state_dir && typeof owner.dd_flow_home === "string" && path.isAbsolute(owner.dd_flow_home))
27
+ return owner.dd_flow_home;
28
+ }
29
+ catch { /* No retained native scope proof. */ }
30
+ }
31
+ if (record.kind === "run-controller" && authority.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_controllers'") && authority.db.get("SELECT 1 FROM run_controllers WHERE controller_id = ? AND process_id = ? AND process_lease_token = ? AND project_id = ? AND run_id = ?", [record.owner_id, record.id, record.lease_token, record.project_id, record.run_id]))
32
+ return authority.ddFlowHome;
33
+ if (record.kind === "run-control" && authority.db.get("SELECT 1 FROM sqlite_schema WHERE name = 'run_control_workers'") && authority.db.get("SELECT 1 FROM run_control_workers w JOIN run_controls c ON c.control_id = w.control_id WHERE w.control_id = ? AND w.process_id = ? AND w.process_lease_token = ? AND c.project_id = ? AND c.run_id = ?", [record.owner_id, record.id, record.lease_token, record.project_id, record.run_id]))
34
+ return authority.ddFlowHome;
35
+ return null;
36
+ }
9
37
  export function registerManagedProcess(context, input) {
38
+ const observer = input.kind === "eval-observer";
39
+ const observerBirth = observer && input.pid ? processStartedAt(input.pid) : null;
40
+ if (observer) {
41
+ if (!Number.isSafeInteger(input.pid) || !input.pid || input.pid < 1 || !observerBirth || input.ownerPid !== undefined && input.ownerPid !== input.pid || input.processGroupId !== undefined && input.processGroupId !== input.pid)
42
+ throw new AppError("process_ownership_unknown", "EVAL observer registration requires its live self-owned process identity", 1);
43
+ input = { ...input, ownerPid: input.pid, uniqueActiveOperation: true };
44
+ }
45
+ if (!input.uniqueActiveOperation)
46
+ return insertManagedProcess(context, input);
47
+ if (!input.operationId)
48
+ throw new Error("Unique managed operation requires operationId");
49
+ const db = registry(context);
50
+ db.beginWriteTransaction();
51
+ try {
52
+ const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
53
+ if (existing)
54
+ throw new AppError("managed_operation_in_progress", "The operation already owns a managed process; observe it instead of spawning a duplicate", 1, { process_id: existing.id, operation_id: input.operationId });
55
+ let result = insertManagedProcess(context, input);
56
+ if (observer) {
57
+ result = confirmManagedProcess(context, { id: result.id, leaseToken: result.lease_token, pid: input.pid, ...(input.processGroupId ? { processGroupId: input.processGroupId } : {}) });
58
+ if (result.pid_started_at !== observerBirth || !processIsAlive(result))
59
+ throw new AppError("process_ownership_unknown", "EVAL observer changed before registration committed", 1);
60
+ }
61
+ db.exec("COMMIT");
62
+ return result;
63
+ }
64
+ catch (error) {
65
+ db.exec("ROLLBACK");
66
+ throw error;
67
+ }
68
+ }
69
+ function insertManagedProcess(context, input) {
10
70
  const db = registry(context);
11
71
  const now = context.now();
12
72
  const id = input.id ?? `PROC-${crypto.randomUUID()}`;
13
73
  const token = crypto.randomUUID();
14
74
  db.run(`INSERT INTO managed_processes
15
75
  (id, kind, pid, pid_started_at, owner_id, lease_token, lease_expires_at, project_id, run_id, work_id, check_id, operation_id, stdout_path, stderr_path, state, started_at, updated_at, finished_at, termination_reason, metadata_json)
16
- VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify(input.metadata ?? {})]);
76
+ VALUES (?, ?, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, NULL, NULL, ?)`, [id, input.kind, input.ownerId, token, leaseExpiry(now, input.leaseMs), input.projectId ?? null, input.runId ?? null, input.workId ?? null, input.checkId ?? null, input.operationId ?? null, input.stdoutPath ?? null, input.stderrPath ?? null, now, now, JSON.stringify({ ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) })]);
17
77
  return requireProcess(db, id);
18
78
  }
19
79
  export function confirmManagedProcess(context, input) {
@@ -23,6 +83,10 @@ export function confirmManagedProcess(context, input) {
23
83
  const metadata = parseMetadata(current.metadata_json);
24
84
  if (input.processGroupId)
25
85
  metadata.process_group_id = input.processGroupId;
86
+ if (input.ownerPid) {
87
+ metadata.owner_pid = input.ownerPid;
88
+ metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
89
+ }
26
90
  const result = db.run(`UPDATE managed_processes
27
91
  SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
28
92
  WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, processStartedAt(input.pid), leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
@@ -36,11 +100,18 @@ export function heartbeatManagedProcess(context, input) {
36
100
  }
37
101
  export function finishManagedProcess(context, input) {
38
102
  const db = registry(context);
103
+ const existing = requireProcess(db, input.id);
104
+ if (existing.lease_token === input.leaseToken && existing.state === input.state)
105
+ return true;
39
106
  const now = context.now();
40
107
  const updated = db.run("UPDATE managed_processes SET state = ?, termination_reason = ?, finished_at = ?, updated_at = ?, lease_expires_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned')", [input.state, input.reason ?? null, now, now, now, input.id, input.leaseToken]).changes === 1;
41
108
  if (updated)
42
109
  db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
43
- return updated;
110
+ if (updated)
111
+ return true;
112
+ // Another client may have completed the same lease after our initial read.
113
+ const current = requireProcess(db, input.id);
114
+ return current.lease_token === input.leaseToken && current.state === input.state;
44
115
  }
45
116
  export function processIsAlive(record) {
46
117
  if (!record.pid)
@@ -48,33 +119,49 @@ export function processIsAlive(record) {
48
119
  try {
49
120
  process.kill(record.pid, 0);
50
121
  }
51
- catch {
52
- return false;
122
+ catch (error) {
123
+ return error.code !== "ESRCH";
53
124
  }
54
- return !record.pid_started_at || record.pid_started_at === processStartedAt(record.pid);
125
+ const started = processStartedAt(record.pid);
126
+ return !record.pid_started_at || !started || record.pid_started_at === started;
55
127
  }
56
- function processTreeIsAlive(record) {
128
+ export function processTreeIsAlive(record) {
129
+ if (processIsAlive(record))
130
+ return true;
57
131
  const group = parseMetadata(record.metadata_json).process_group_id;
58
132
  if (process.platform !== "win32" && typeof group === "number") {
59
133
  try {
60
134
  process.kill(-group, 0);
61
135
  return true;
62
136
  }
63
- catch {
64
- return false;
137
+ catch (error) {
138
+ return error.code !== "ESRCH";
65
139
  }
66
140
  }
67
- return processIsAlive(record);
141
+ return false;
68
142
  }
69
- /** Claims only expired records. Callers must still verify `processIsAlive` before stopping a PID. */
143
+ export function managedOwnerIsAlive(record) {
144
+ const metadata = parseMetadata(record.metadata_json);
145
+ if (typeof metadata.owner_pid !== "number")
146
+ return null;
147
+ return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
148
+ }
149
+ /**
150
+ * Expiry proves that observation stopped, not that the owner died. A live PID
151
+ * remains owned until its result is reconciled; only a dead process can be
152
+ * atomically claimed for cleanup.
153
+ */
70
154
  export function claimExpiredManagedProcesses(context, ownerId) {
71
155
  const db = registry(context);
72
156
  const now = context.now();
73
- db.exec("BEGIN IMMEDIATE");
157
+ db.beginWriteTransaction();
74
158
  try {
75
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]);
76
160
  const claimed = [];
77
161
  for (const candidate of candidates) {
162
+ const ownerAlive = managedOwnerIsAlive(candidate);
163
+ if (ownerAlive === true || (ownerAlive === null && processTreeIsAlive(candidate)))
164
+ continue;
78
165
  const token = crypto.randomUUID();
79
166
  const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
80
167
  if (result.changes === 1)
@@ -88,9 +175,39 @@ export function claimExpiredManagedProcesses(context, ownerId) {
88
175
  throw error;
89
176
  }
90
177
  }
178
+ /** Workspace gates are leases, not spawned processes. Children retain their own registrations. */
179
+ export function managedLeaseReleased(record) {
180
+ const metadata = parseMetadata(record.metadata_json);
181
+ return record.kind === "workspace-gate" && record.pid === null && record.pid_started_at === null
182
+ && metadata.process_group_id == null && metadata.role === "check_gate"
183
+ && ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at);
184
+ }
91
185
  export function managedProcessStatus(context) {
92
186
  return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
93
187
  }
188
+ export async function stopManagedProcess(context, input) {
189
+ const db = registry(context), record = requireProcess(db, input.id);
190
+ if (record.lease_token !== input.leaseToken)
191
+ throw new Error("Managed process lease does not match");
192
+ if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
193
+ return record;
194
+ if (!record.pid || !record.pid_started_at)
195
+ throw Object.assign(new Error("Managed process has no confirmed physical identity; retain its pending launch"), { code: "process_identity_unknown" });
196
+ db.run("UPDATE managed_processes SET state = 'stopping', updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','orphaned')", [context.now(), record.id, record.lease_token]);
197
+ if (processTreeIsAlive(record)) {
198
+ terminateOwnedProcess(record, "SIGTERM");
199
+ await delay(input.graceMs ?? 1_000);
200
+ if (input.force !== false && processTreeIsAlive(record)) {
201
+ terminateOwnedProcess(record, "SIGKILL");
202
+ await delay(250);
203
+ }
204
+ if (processTreeIsAlive(record))
205
+ throw Object.assign(new Error("Owned process tree could not be safely stopped; resources retained"), { code: "process_tree_not_settled", details: { process_id: record.id } });
206
+ }
207
+ if (!finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" }))
208
+ throw Object.assign(new Error("Managed process ownership changed before settlement"), { code: "process_lease_lost" });
209
+ return requireProcess(db, record.id);
210
+ }
94
211
  /** Reconcile only records the system owns and has atomically claimed. */
95
212
  export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
96
213
  const claimed = claimExpiredManagedProcesses(context, ownerId);
@@ -101,18 +218,15 @@ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs
101
218
  outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
102
219
  continue;
103
220
  }
104
- terminateOwnedProcess(processRecord, "SIGTERM");
105
- await delay(graceMs);
106
- if (processTreeIsAlive(processRecord)) {
107
- terminateOwnedProcess(processRecord, "SIGKILL");
108
- await delay(Math.min(graceMs, 250));
221
+ try {
222
+ await stopManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, graceMs, reason: "orphan_reconciled" });
223
+ outcomes.push({ id: processRecord.id, outcome: "stopped" });
109
224
  }
110
- if (processTreeIsAlive(processRecord)) {
225
+ catch (error) {
226
+ if (error.code !== "process_tree_not_settled")
227
+ throw error;
111
228
  outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
112
- continue;
113
229
  }
114
- finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
115
- outcomes.push({ id: processRecord.id, outcome: "stopped" });
116
230
  }
117
231
  return outcomes;
118
232
  }
@@ -146,11 +260,13 @@ function registry(context) { return getResourceDatabase(resourceHome(context));
146
260
  function requireProcess(db, id) { const row = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]); if (!row)
147
261
  throw new Error(`Managed process is missing: ${id}`); return row; }
148
262
  function leaseExpiry(now, leaseMs = defaultLeaseMs) { return new Date(Date.parse(now) + leaseMs).toISOString(); }
149
- function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
263
+ export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
150
264
  function releasePortClaims(db, claims) { for (const claim of claims)
151
265
  db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
152
266
  function terminateOwnedProcess(record, signal) {
153
- if (!record.pid || !record.pid_started_at || !processTreeIsAlive(record))
267
+ // Never signal a group merely because its former leader PID was once ours.
268
+ // PID reuse or a departed leader makes group ownership unprovable.
269
+ if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
154
270
  return;
155
271
  const group = parseMetadata(record.metadata_json).process_group_id;
156
272
  try {
@@ -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) {
@@ -1,10 +1,14 @@
1
+ import { managedInvocationContext, managedLifecycleCommand } from "./lifecycle-invocations.js";
1
2
  import crypto from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { AppError } from "../shared/errors.js";
6
+ import { runtimeBudgetForRun } from "./runtime-budget.js";
5
7
  import { flowCommand } from "./stage-pause.js";
6
- import { validateSchema } from "./schema-validation.js";
7
- import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
8
+ import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
9
+ import { harnessRuntimeArguments, resolveHarnessCommand } from "./harness-config.js";
10
+ import { assertRunMutationAllowed, recoveryGuard } from "./run-recovery.js";
11
+ import { loadAgentProfile, resolveExecutionPolicy, runtimeHarness } from "./execution-policy.js";
8
12
  /** A deterministic dispatcher; only the launched harness Session performs agent work. */
9
13
  export async function serveMergeRequests(context, input) {
10
14
  const running = context.db.get("SELECT server_id, pid FROM merge_servers WHERE status IN ('running','stopping') ORDER BY started_at LIMIT 1");
@@ -57,42 +61,90 @@ export async function serveMergeRequests(context, input) {
57
61
  export function mergeServerStatus(context) { return { ok: true, servers: context.db.all("SELECT server_id, profile_id, status, pid, started_at, heartbeat_at, stopped_at, journal_path FROM merge_servers ORDER BY started_at DESC") }; }
58
62
  export function stopMergeServer(context, input) { const changed = context.db.run("UPDATE merge_servers SET status = 'stopping', heartbeat_at = ? WHERE server_id = ? AND status = 'running'", [context.now(), input.serverId]); if (changed.changes !== 1)
59
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
+ /** Both dispatchers enter the same project FIFO before creating any provider Session. */
65
+ export function claimQueuedMergeRequest(context, input) {
66
+ context.db.beginWriteTransaction();
67
+ try {
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
+ if (!request)
70
+ throw new AppError("not_found", "MERGE request was not found", 1);
71
+ const generation = assertRunMutationAllowed(context, request.project_id, request.run_id, input.expectedGeneration);
72
+ const earlier = context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]);
73
+ if (request.status !== "queued" || request.execution_route !== "server" || earlier) {
74
+ context.db.exec("COMMIT");
75
+ return null;
76
+ }
77
+ const token = crypto.randomUUID();
78
+ context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued'", [input.ownerId, token, new Date(Date.now() + 120_000).toISOString(), context.now(), request.merge_request_id]);
79
+ context.db.exec("COMMIT");
80
+ return { request, token, generation };
81
+ }
82
+ catch (error) {
83
+ context.db.exec("ROLLBACK");
84
+ throw error;
85
+ }
86
+ }
60
87
  async function dispatch(context, input) {
61
- const token = crypto.randomUUID();
62
- const leaseUntil = new Date(Date.now() + 120_000).toISOString();
63
- const claimed = context.db.run("UPDATE merge_requests SET status = 'dispatching', dispatch_owner = ?, dispatch_lease_token = ?, dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'queued' AND execution_route = 'server'", [input.serverId, token, leaseUntil, context.now(), input.request.merge_request_id]);
64
- if (claimed.changes !== 1)
88
+ const run = context.db.get("SELECT index_json, project_root FROM runs WHERE project_id = ? AND id = ?", [input.request.project_id, input.request.run_id]);
89
+ if (!run)
90
+ throw new AppError("not_found", "MERGE RUN was not found", 1, { run_id: input.request.run_id });
91
+ const snapshot = JSON.parse(run.index_json).execution_profile;
92
+ if (snapshot?.settings?.execution) {
93
+ if (!snapshot.agent_profiles)
94
+ throw new AppError("execution_profile_not_frozen", "MERGE dispatch requires frozen RUN agent profiles", 1, { run_id: input.request.run_id });
95
+ const resolved = resolveExecutionPolicy({ ddFlowHome: context.ddFlowHome, policy: snapshot.settings.execution, profiles: snapshot.agent_profiles, stage: "merge" });
96
+ input = { ...input, profile: resolved.coordinator };
97
+ }
98
+ const claim = claimQueuedMergeRequest(context, { requestId: input.request.merge_request_id, ownerId: input.serverId });
99
+ if (!claim)
65
100
  return;
66
- const leaseHeartbeat = setInterval(() => { const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
67
- leaseHeartbeat.unref();
101
+ const { generation, token } = claim;
68
102
  const stateDir = path.join(input.root, input.request.merge_request_id);
69
103
  fs.mkdirSync(stateDir, { recursive: true });
70
104
  const promptFile = path.join(stateDir, "launch.md");
71
105
  const adapterJournal = path.join(stateDir, "adapter.events.jsonl");
72
- const stageCommand = `${flowCommand(context)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(input.request.target_workspace)} --json --progress-jsonl`;
73
- fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
106
+ const flowExecutable = prepareHarnessFlowExecutable(context, stateDir);
107
+ let runtimeContext = { ...context, env: { ...context.env, DD_FLOW_BIN: flowExecutable } };
108
+ const runtimeOwner = { schema_id: "dd-flow/runtime-owner@1", owner_id: `run:${input.request.run_id}:merge:${input.request.merge_request_id}`, role: "merge", project_id: input.request.project_id, run_id: input.request.run_id, work_id: input.request.executor_work_id, operation_id: `${input.request.merge_request_id}:${token}`, state_dir: stateDir, dd_flow_home: context.ddFlowHome, dd_flow_bin: flowExecutable, resource_home: context.env.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome, budget: runtimeBudgetForRun(context, input.request.project_id, input.request.run_id) };
74
109
  event(input.journal, { type: "launch_intent", server_id: input.serverId, request_id: input.request.merge_request_id, token, profile: input.profile, prompt_file: promptFile, at: context.now() });
75
110
  input.progress?.(`launching ${input.request.merge_request_id} with ${input.profile.id}`);
76
- const executable = context.env[`DD_FLOW_${input.profile.harness.toUpperCase()}_ADAPTER`] ?? `dd-${input.profile.harness}`;
77
- const common = ["--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", input.request.target_workspace, "--dd-flow-home", context.ddFlowHome, "--json"];
111
+ const executable = resolveHarnessCommand(context.ddFlowHome, input.profile.harness, "adapter");
112
+ const common = ["--dd-flow-bin", flowExecutable, "--state-dir", stateDir, "--journal", adapterJournal, "--cwd", input.request.target_workspace, "--provider", input.profile.provider, "--model", input.profile.model, "--reasoning", input.profile.reasoning, "--mode", input.profile.mode, "--permission", input.profile.permission, "--project-root", run.project_root, "--dd-flow-home", context.ddFlowHome, ...harnessRuntimeArguments(context.ddFlowHome, input.profile.harness), "--json"];
113
+ const retained = { profile: input.profile, executable, owner: runtimeOwner, session_id: null, state_dir: stateDir, create_operation_id: `${input.request.merge_request_id}:${token}:session.create`, prompt_operation_id: `${input.request.merge_request_id}:${token}:session.prompt` };
114
+ const retain = () => { context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ? AND dispatch_owner = ? AND dispatch_lease_token = ?", [JSON.stringify(retained), context.now(), input.request.merge_request_id, input.serverId, token]); };
115
+ retain();
116
+ const leaseHeartbeat = setInterval(() => { if (guarded(context, input.request) || (recoveryGuard(context, input.request.project_id, input.request.run_id)?.generation ?? 0) !== generation)
117
+ return; const now = context.now(); context.db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE merge_request_id = ? AND status = 'dispatching' AND dispatch_owner = ? AND dispatch_lease_token = ?", [new Date(Date.now() + 120_000).toISOString(), now, input.request.merge_request_id, input.serverId, token]); }, 30_000);
118
+ leaseHeartbeat.unref();
78
119
  try {
79
- const adapterCall = (args, timeoutMs, progressMessage) => runHarnessAdapter({ executable, args, env: context.env, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) });
120
+ const adapterCall = (args, timeoutMs, progressMessage) => { assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation); return runHarnessAdapter({ executable, args, env: runtimeContext.env, runtimeOwner, operationId: `${input.request.merge_request_id}:${token}:${args[0]}.${args[1]}`, ...(timeoutMs ? { timeoutMs } : {}), ...(input.progress ? { progress: input.progress } : {}), ...(progressMessage ? { progressMessage } : {}), onEvidence: (value) => event(input.journal, { type: "adapter_call", ...value }) }); };
80
121
  await adapterCall(["daemon", "start", ...common]);
81
122
  const created = await adapterCall(["session", "create", ...common]);
82
123
  const sessionId = adapterSessionId(created);
83
124
  if (!sessionId)
84
125
  throw new AppError("merge_adapter_invalid", "Harness adapter did not return a Session id", 1, { harness: input.profile.harness, response: created });
126
+ Object.assign(retained, { session_id: sessionId, create: created });
127
+ retain();
128
+ runtimeContext = managedInvocationContext(runtimeContext, { harness: runtimeHarness(input.profile), stateDir, sessionId, projectRoot: run.project_root, runId: input.request.run_id, generation });
129
+ const stageCommand = managedLifecycleCommand(runtimeContext, `${flowCommand(runtimeContext)} stage start ${input.request.run_id} --stage merge --project-root ${JSON.stringify(run.project_root)} --json --progress-jsonl`);
130
+ fs.writeFileSync(promptFile, `Start the assigned MERGE stage now. Your first tool call must be this exact standalone command:\n\n${stageCommand}\n\nTrust and follow the complete stage packet it returns. Continue through merge apply and stage finish. Stop only when the stage reaches a terminal result or explicitly requests user/operator action.\n`);
85
131
  const prompted = await adapterCall(["session", "prompt", "--session-id", sessionId, "--prompt-file", promptFile, ...common], 45 * 60 * 1000, `MERGE ${input.request.merge_request_id} Session ${sessionId} is still running`);
132
+ Object.assign(retained, { response: prompted });
133
+ retain();
134
+ assertRunMutationAllowed(context, input.request.project_id, input.request.run_id, generation);
86
135
  const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
87
- context.db.run("UPDATE merge_requests SET adapter_receipt_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ profile: input.profile, executable, session_id: sessionId, response: prompted }), context.now(), input.request.merge_request_id]);
88
136
  event(input.journal, { type: "adapter_settled", request_id: input.request.merge_request_id, session_id: sessionId, request_status: request?.status ?? null, at: context.now() });
89
137
  if (request?.status === "dispatching")
90
- context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
138
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_did_not_start_stage", response: prompted }), context.now(), input.request.merge_request_id]);
91
139
  }
92
140
  catch (error) {
141
+ if (error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code)) {
142
+ event(input.journal, { type: "recovery_dispatch_blocked", request_id: input.request.merge_request_id, code: error.code, at: context.now() });
143
+ throw error;
144
+ }
93
145
  const request = context.db.get("SELECT status FROM merge_requests WHERE merge_request_id = ?", [input.request.merge_request_id]);
94
146
  if (request?.status === "dispatching")
95
- context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
147
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_launch_failed", error: String(error) }), context.now(), input.request.merge_request_id]);
96
148
  else if (request?.status === "active")
97
149
  context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "adapter_lost_after_stage_start", error: String(error) }), context.now(), input.request.merge_request_id]);
98
150
  throw error;
@@ -101,23 +153,17 @@ async function dispatch(context, input) {
101
153
  clearInterval(leaseHeartbeat);
102
154
  }
103
155
  }
104
- function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
156
+ function nextServerRequests(context, limit) { const selected = new Set(); return context.db.all("SELECT merge_request_id, project_id, run_id, executor_work_id, target_workspace, execution_route, status, created_at FROM merge_requests WHERE status = 'queued' AND execution_route = 'server' ORDER BY created_at, merge_request_id").filter((request) => { if (guarded(context, request) || selected.has(request.project_id) || context.db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND status NOT IN ('completed','failed','cancelled','superseded') AND (created_at < ? OR (created_at = ? AND merge_request_id < ?)) LIMIT 1", [request.project_id, request.created_at, request.created_at, request.merge_request_id]))
105
157
  return false; selected.add(request.project_id); return true; }).slice(0, limit); }
106
- function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
158
+ function reconcileExpiredDispatches(context) { const expired = context.db.all("SELECT merge_request_id, executor_work_id, project_id, run_id FROM merge_requests WHERE status = 'dispatching' AND dispatch_lease_expires_at < ?", [context.now()]); for (const request of expired) {
159
+ if (guarded(context, request))
160
+ continue;
107
161
  const work = context.db.get("SELECT status FROM works WHERE work_id = ?", [request.executor_work_id]);
108
- if (work?.status === "created")
109
- context.db.run("UPDATE merge_requests SET status = 'queued', dispatch_owner = NULL, dispatch_lease_token = NULL, dispatch_lease_expires_at = NULL, last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_requeued" }), context.now(), request.merge_request_id]);
110
- else
111
- context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_with_started_work", work_status: work?.status ?? null }), context.now(), request.merge_request_id]);
162
+ context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE merge_request_id = ?", [JSON.stringify({ code: "expired_dispatch_outcome_unknown", work_status: work?.status ?? null }), context.now(), request.merge_request_id]);
112
163
  } }
113
- function loadProfile(context, id) { const file = path.join(context.ddFlowHome, "agent-profiles", `${id}.json`); let value; try {
114
- value = JSON.parse(fs.readFileSync(file, "utf8"));
115
- }
116
- catch (error) {
117
- throw new AppError("agent_profile_missing", "Merge-server agent profile is unavailable", 1, { id, file, cause: String(error) });
118
- } validateSchema({ schemaName: "agent-profile", file }); if (value.id !== id)
119
- throw new AppError("agent_profile_invalid", "Agent profile id does not match its requested filename", 2, { file, expected: id, actual: value.id ?? null }); return value; }
164
+ function loadProfile(context, id) { return loadAgentProfile(context.ddFlowHome, id); }
120
165
  function event(file, value) { fs.appendFileSync(file, `${JSON.stringify(value)}\n`); }
166
+ function guarded(context, request) { const guard = recoveryGuard(context, request.project_id, request.run_id); return Boolean(guard && ["draining", "sealed", "resuming"].includes(guard.status)); }
121
167
  function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
122
168
  function processAlive(pid) { if (!pid)
123
169
  return false; try {
@@ -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
  }
@@ -0,0 +1,49 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { AppError } from "../shared/errors.js";
5
+ import { managedProcessBinding, managedProcessStatus, processTreeIsAlive, resourceHome } from "./managed-processes.js";
6
+ import { retainedDaemonOperations } from "./run-control.js";
7
+ import { managedProviderBelongsToDaemon } from "./managed-daemon-binding.js";
8
+ /** A replaced daemon is a settled resource, never another Session to resume. */
9
+ export function historicalDaemonSettlement(context, scopeId, record) {
10
+ const metadata = JSON.parse(record.metadata_json);
11
+ if (!record.kind.endsWith("-daemon") || !metadata.state_dir || !path.isAbsolute(metadata.state_dir))
12
+ throw new AppError("runtime_scope_owner_unknown", "Native history requires an absolute daemon directory", 1);
13
+ const current = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
14
+ if (current.resource_process?.id === record.id)
15
+ return null;
16
+ if (record.state !== "stopped" || record.termination_reason !== "daemon_stopped" || !record.pid || !record.pid_started_at || processTreeIsAlive(record))
17
+ throw new AppError("runtime_scope_owner_unknown", "Previous daemon has no physical completion proof", 1);
18
+ const directory = path.join(metadata.state_dir, "daemon-history");
19
+ const matches = fs.readdirSync(directory).filter(name => /^[a-zA-Z0-9-]+\.json$/.test(name)).map(name => {
20
+ const file = path.join(directory, name);
21
+ if (!fs.lstatSync(file).isFile())
22
+ throw new AppError("runtime_scope_owner_unknown", "Daemon history must contain regular receipts", 1);
23
+ const bytes = fs.readFileSync(file, "utf8");
24
+ return { file, bytes, state: JSON.parse(bytes) };
25
+ }).filter(candidate => candidate.state.resource_process?.id === record.id);
26
+ if (matches.length !== 1)
27
+ throw new AppError("runtime_scope_owner_unknown", "Previous daemon has no unique retained receipt", 1);
28
+ const { file, bytes, state } = matches[0], owner = state.config?.runtime_owner;
29
+ if (!state.daemon_id || path.basename(file) !== `${state.daemon_id}.json` || !owner || owner.budget?.scope_id !== scopeId || owner.owner_id !== record.owner_id || owner.role !== metadata.role || owner.state_dir !== metadata.state_dir || owner.adapter_executable !== metadata.adapter_executable || owner.dd_flow_home !== metadata.dd_flow_home || owner.resource_home !== resourceHome(context) || (owner.project_id ?? null) !== record.project_id || (owner.run_id ?? null) !== record.run_id || (owner.work_id ?? null) !== record.work_id || !record.operation_id?.startsWith(`${owner.operation_id}:`) || state.resource_process?.lease_token !== record.lease_token || state.pid !== record.pid)
30
+ throw new AppError("runtime_scope_owner_unknown", "Archived daemon does not match its registration", 1);
31
+ const closed = state.shutdown_state === "clean" && state.active_tree === false
32
+ ? retainedDaemonOperations(metadata.state_dir).find(operation => operation.daemon_id === state.daemon_id && operation.operation === "daemon.stop" && operation.state === "completed" && operation.result?.stopped === true && operation.result?.clean === true) : undefined;
33
+ const fresh = managedProcessStatus(context).find(item => item.id === record.id);
34
+ if (!closed || !fresh || fresh.lease_token !== record.lease_token || fresh.pid !== record.pid || fresh.pid_started_at !== record.pid_started_at || fresh.metadata_json !== record.metadata_json || fresh.state !== "stopped" || fresh.termination_reason !== "daemon_stopped" || processTreeIsAlive(fresh) || fs.readFileSync(file, "utf8") !== bytes)
35
+ throw new AppError("runtime_scope_outcome_unknown", "Previous daemon lacks stable clean shutdown evidence", 1);
36
+ const providers = managedProcessStatus(context).filter(item => managedProviderBelongsToDaemon(item, fresh, state));
37
+ if (state.provider_process && (providers.length !== 1 || providers[0].state !== "stopped" || providers[0].termination_reason !== "daemon_stopped" || !providers[0].operation_id?.startsWith(`${owner.operation_id}:`) || processTreeIsAlive(providers[0])))
38
+ throw new AppError("runtime_scope_outcome_unknown", "Archived provider lacks its exact physical completion proof", 1);
39
+ return { historical: true, process_id: record.id, role: metadata.role, state_dir: metadata.state_dir, daemon_id: state.daemon_id,
40
+ processes: providers.map(item => ({ process_id: item.id, native_settled: true, binding: managedProcessBinding(item, resourceHome(context)) })),
41
+ settled: true, native_settled: true, operation_id: String(closed.operation_id), owner_operation_id: owner.operation_id,
42
+ inventory_version: crypto.createHash("sha256").update(JSON.stringify([bytes, closed])).digest("hex") };
43
+ }
44
+ export function assertHistoricalDaemonSettlement(context, scopeId, proof) {
45
+ const record = managedProcessStatus(context).find(item => item.id === proof.process_id);
46
+ const fresh = record && historicalDaemonSettlement(context, scopeId, record);
47
+ if (!fresh || proof.historical !== true || proof.native_settled !== true || fresh.inventory_version !== proof.inventory_version)
48
+ throw new AppError("runtime_scope_inventory_changed", "Archived daemon settlement changed after observation", 1);
49
+ }
@@ -0,0 +1,39 @@
1
+ import { AppError } from "../shared/errors.js";
2
+ import { adapterSessionId, runHarnessAdapter } from "./harness-adapter.js";
3
+ export async function cancelControlledNativeSession(input) {
4
+ input.assertCurrent();
5
+ const reply = await runHarnessAdapter({ executable: input.executable,
6
+ args: input.observe ? ["daemon", "operation", "--operation-id", input.operationId, "--state-dir", input.stateDir, "--json"] : ["session", "cancel", "--session-id", input.sessionId, "--state-dir", input.stateDir, "--json"],
7
+ env: input.env, operationId: input.observe ? `${input.operationId}:observe` : input.operationId, timeoutMs: 30_000 });
8
+ if (input.observe && (reply.operation_id !== input.operationId || reply.operation !== "session.cancel" || reply.session_id !== input.sessionId))
9
+ throw new AppError("controller_operation_mismatch", "Cancellation observation belongs to another operation or Session", 1);
10
+ if (input.observe && reply.state === "failed") {
11
+ if (!reply.error || typeof reply.error !== "object" || Array.isArray(reply.error))
12
+ throw new AppError("controller_operation_mismatch", "Cancellation failure has no terminal error", 1);
13
+ input.assertCurrent();
14
+ return { status: "failed", receipt: reply };
15
+ }
16
+ const result = input.observe ? reply.state === "completed" ? reply.result : null : reply;
17
+ // A completed cancel command is an immutable ACK, not proof that the
18
+ // provider tree has stopped. Callers must use a separate fresh inspection.
19
+ if (!input.observe || reply.state === "completed")
20
+ assertNativeCancellationReceipt(result, input.sessionId);
21
+ input.assertCurrent();
22
+ return { status: result ? "completed" : "outcome_unknown", receipt: (result ?? reply) };
23
+ }
24
+ export function assertNativeCancellationReceipt(value, sessionId) {
25
+ const identity = adapterSessionId(value);
26
+ if (!value || typeof value !== "object" || Array.isArray(value) || identity && identity !== sessionId)
27
+ throw new AppError("controller_operation_mismatch", "Cancellation receipt has no valid result for its target Session", 1);
28
+ }
29
+ /** Native observation is shared by RUN and non-Work EVAL owners. */
30
+ export async function inspectControlledNativeSession(input) {
31
+ input.assertCurrent();
32
+ const receipt = await runHarnessAdapter({ executable: input.executable, args: ["session", "inspect", "--session-id", input.sessionId, "--state-dir", input.stateDir, "--json"], env: input.env, operationId: input.operationId, timeoutMs: 30_000 });
33
+ if (adapterSessionId(receipt) !== input.sessionId)
34
+ throw new AppError("controller_operation_mismatch", "Session observation belongs to another target", 1);
35
+ if (input.harness && receipt.harness !== undefined && receipt.harness !== input.harness)
36
+ throw new AppError("controller_operation_mismatch", "Session observation belongs to another harness", 1);
37
+ input.assertCurrent();
38
+ return receipt;
39
+ }