@deksden-com/dd-flow-cli 0.9.0-beta.13 → 0.9.0-beta.133

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 (204) hide show
  1. package/CHANGELOG.md +666 -0
  2. package/README.md +101 -1
  3. package/dist/build-info.json +10 -10
  4. package/dist/cli/argument-values.js +38 -0
  5. package/dist/cli/command-inputs.js +306 -0
  6. package/dist/cli/help.js +88 -12
  7. package/dist/cli/hook-ingress.js +78 -0
  8. package/dist/cli/input-preparation.js +116 -0
  9. package/dist/cli/native-hook-ingress.js +80 -0
  10. package/dist/cli/run-cli.js +1465 -450
  11. package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
  12. package/dist/cli/runtime-maintenance-ingress.js +52 -0
  13. package/dist/cli/runtime-process.js +153 -0
  14. package/dist/cli.js +17 -2
  15. package/dist/domain/stage-catalog.js +1 -1
  16. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  17. package/dist/harness-runtime/bin/dd-agy.mjs +56 -0
  18. package/dist/harness-runtime/bin/dd-codex.mjs +27 -0
  19. package/dist/harness-runtime/bin/dd-droid.mjs +34 -0
  20. package/dist/harness-runtime/bin/dd-grok.mjs +40 -0
  21. package/dist/harness-runtime/bin/dd-opencode.mjs +22 -0
  22. package/dist/harness-runtime/bin/dd-zcode.mjs +87 -0
  23. package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
  24. package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
  25. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  26. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  27. package/dist/harness-runtime/lib/codex-capacity-policy.d.mts +10 -0
  28. package/dist/harness-runtime/lib/codex-capacity-policy.mjs +89 -0
  29. package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
  30. package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
  31. package/dist/harness-runtime/lib/daemon-operations.mjs +370 -0
  32. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  33. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +693 -0
  34. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  35. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  36. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +190 -0
  37. package/dist/harness-runtime/lib/dd-codex.d.mts +1 -0
  38. package/dist/harness-runtime/lib/dd-codex.mjs +805 -0
  39. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +128 -0
  40. package/dist/harness-runtime/lib/dd-droid.mjs +561 -0
  41. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +490 -0
  42. package/dist/harness-runtime/lib/dd-grok.mjs +177 -0
  43. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +255 -0
  44. package/dist/harness-runtime/lib/dd-opencode.mjs +128 -0
  45. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +703 -0
  46. package/dist/harness-runtime/lib/dd-zcode.mjs +1108 -0
  47. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  48. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  49. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  50. package/dist/harness-runtime/lib/driver-recovery.mjs +139 -0
  51. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  52. package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
  53. package/dist/harness-runtime/lib/lease-renewal.mjs +36 -0
  54. package/dist/harness-runtime/lib/lifecycle-command.d.mts +20 -0
  55. package/dist/harness-runtime/lib/lifecycle-command.mjs +272 -0
  56. package/dist/harness-runtime/lib/lifecycle-contract.d.mts +10 -0
  57. package/dist/harness-runtime/lib/lifecycle-contract.mjs +19 -0
  58. package/dist/harness-runtime/lib/managed-daemon.d.mts +12 -0
  59. package/dist/harness-runtime/lib/managed-daemon.mjs +619 -0
  60. package/dist/harness-runtime/lib/model-observations.mjs +101 -0
  61. package/dist/harness-runtime/lib/native-children.d.mts +13 -0
  62. package/dist/harness-runtime/lib/native-children.mjs +78 -0
  63. package/dist/harness-runtime/lib/native-hook-command.d.mts +7 -0
  64. package/dist/harness-runtime/lib/native-hook-command.mjs +251 -0
  65. package/dist/harness-runtime/lib/native-streams.mjs +45 -0
  66. package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
  67. package/dist/harness-runtime/lib/observation-clock.mjs +92 -0
  68. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  69. package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
  70. package/dist/harness-runtime/lib/operation-errors.mjs +116 -0
  71. package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
  72. package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
  73. package/dist/harness-runtime/lib/process-json.d.mts +5 -0
  74. package/dist/harness-runtime/lib/process-json.mjs +201 -0
  75. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  76. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  77. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  78. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  79. package/dist/harness-runtime/lib/setup-progress.d.mts +9 -0
  80. package/dist/harness-runtime/lib/setup-progress.mjs +51 -0
  81. package/dist/harness-runtime/lib/tool-observations.d.mts +21 -0
  82. package/dist/harness-runtime/lib/tool-observations.mjs +228 -0
  83. package/dist/harness-runtime/lib/zcode-usage.mjs +55 -0
  84. package/dist/protocol/local-files.js +18 -9
  85. package/dist/runtime/context.js +8 -4
  86. package/dist/schemas/agent-profile.schema.json +1 -1
  87. package/dist/schemas/code-review-result.schema.json +1 -1
  88. package/dist/schemas/code-verification.schema.json +7 -5
  89. package/dist/schemas/code-work-batch.schema.json +2 -2
  90. package/dist/schemas/flow-run.schema.json +2 -0
  91. package/dist/schemas/plan-review-decision.schema.json +3 -3
  92. package/dist/schemas/plan-review-result.schema.json +2 -2
  93. package/dist/schemas/run-control-receipt.schema.json +118 -0
  94. package/dist/schemas/run-control-request.schema.json +36 -0
  95. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  96. package/dist/services/branch-context.js +3 -3
  97. package/dist/services/canon.js +11 -8
  98. package/dist/services/check-runner-evidence.js +121 -0
  99. package/dist/services/cleanup.js +133 -40
  100. package/dist/services/cli-operation-classifier.js +22 -24
  101. package/dist/services/code-checks.js +851 -109
  102. package/dist/services/codex-capacity.js +367 -0
  103. package/dist/services/codex-hook-delivery.js +32 -0
  104. package/dist/services/command-context.js +80 -0
  105. package/dist/services/compatibility-preflight.js +13 -3
  106. package/dist/services/config.js +11 -8
  107. package/dist/services/continuation-outcome.js +20 -0
  108. package/dist/services/controller-fanout.js +119 -0
  109. package/dist/services/dashboard.js +135 -56
  110. package/dist/services/engines.js +245 -119
  111. package/dist/services/eval-snapshots.js +1146 -105
  112. package/dist/services/execution-policy.js +181 -0
  113. package/dist/services/external-work-launch.js +230 -0
  114. package/dist/services/harness-adapter.js +329 -36
  115. package/dist/services/harness-config.js +2 -0
  116. package/dist/services/hooks.js +747 -325
  117. package/dist/services/lanes.js +78 -65
  118. package/dist/services/lifecycle-command.js +1 -288
  119. package/dist/services/lifecycle-contract.js +1 -0
  120. package/dist/services/lifecycle-invocations.js +1774 -0
  121. package/dist/services/managed-daemon-binding.js +38 -0
  122. package/dist/services/managed-lease-monitor.js +133 -0
  123. package/dist/services/managed-processes.js +409 -51
  124. package/dist/services/merge-queue.js +228 -149
  125. package/dist/services/merge-server.js +163 -35
  126. package/dist/services/migrations.js +37 -16
  127. package/dist/services/native-daemon-history.js +49 -0
  128. package/dist/services/native-session-control.js +39 -0
  129. package/dist/services/plan-runtime.js +26 -11
  130. package/dist/services/plans.js +28 -25
  131. package/dist/services/projects.js +20 -15
  132. package/dist/services/prompts.js +28 -17
  133. package/dist/services/protocols.js +163 -87
  134. package/dist/services/recovery-observation-budget.js +121 -0
  135. package/dist/services/recovery-snapshot-database.js +145 -0
  136. package/dist/services/repair-intents.js +42 -0
  137. package/dist/services/review-copy.js +157 -0
  138. package/dist/services/review-deferral.js +23 -0
  139. package/dist/services/run-control-receipt.js +56 -0
  140. package/dist/services/run-control-worker.js +411 -0
  141. package/dist/services/run-control.js +985 -0
  142. package/dist/services/run-controller-adapter.js +399 -0
  143. package/dist/services/run-controller-capture.js +179 -0
  144. package/dist/services/run-controller-process.js +187 -0
  145. package/dist/services/run-controller-recovery.js +289 -0
  146. package/dist/services/run-controller-state.js +97 -0
  147. package/dist/services/run-controller.js +1035 -0
  148. package/dist/services/run-engine-bindings.js +1 -1
  149. package/dist/services/run-fork.js +147 -0
  150. package/dist/services/run-observations.js +152 -0
  151. package/dist/services/run-projection.js +22 -64
  152. package/dist/services/run-recovery-guard.js +75 -0
  153. package/dist/services/run-recovery-runtime.js +112 -0
  154. package/dist/services/run-recovery.js +349 -0
  155. package/dist/services/runs.js +558 -217
  156. package/dist/services/runtime-budget.js +430 -0
  157. package/dist/services/runtime-command.js +43 -0
  158. package/dist/services/runtime-scope-capture.js +52 -0
  159. package/dist/services/runtime-scope-control.js +468 -0
  160. package/dist/services/runtime-scope-resume.js +599 -0
  161. package/dist/services/runtime-scope-stop.js +180 -0
  162. package/dist/services/runtime-scope-worker.js +287 -0
  163. package/dist/services/runtime-service.js +96 -0
  164. package/dist/services/schema-validation.js +10 -8
  165. package/dist/services/scope-writer-cleanup.js +27 -0
  166. package/dist/services/sessions.js +35 -69
  167. package/dist/services/stage-blocker.js +17 -7
  168. package/dist/services/stage-context.js +185 -19
  169. package/dist/services/stage-lifecycle.js +98 -107
  170. package/dist/services/stage-pause.js +89 -75
  171. package/dist/services/stage-report-renderer.js +158 -6
  172. package/dist/services/stage-work-graph.js +35 -0
  173. package/dist/services/tooling.js +32 -23
  174. package/dist/services/usage.js +49 -101
  175. package/dist/services/verification-matrix.js +374 -0
  176. package/dist/services/vnext-code-review.js +222 -87
  177. package/dist/services/vnext-code.js +324 -251
  178. package/dist/services/vnext-contracts.js +10 -1
  179. package/dist/services/vnext-execution-profile.js +79 -4
  180. package/dist/services/vnext-fanout.js +177 -17
  181. package/dist/services/vnext-merge.js +335 -113
  182. package/dist/services/vnext-plan-review.js +276 -126
  183. package/dist/services/vnext-plan.js +227 -102
  184. package/dist/services/vnext-protocolize.js +335 -82
  185. package/dist/services/vnext-specify.js +119 -81
  186. package/dist/services/work-registry.js +1038 -192
  187. package/dist/services/workspace-bootstrap.js +87 -0
  188. package/dist/services/workspace-files.js +85 -0
  189. package/dist/services/worktrees.js +98 -44
  190. package/dist/shared/entity-references.js +8 -0
  191. package/dist/shared/errors.js +17 -0
  192. package/dist/storage/agent-output.js +26 -0
  193. package/dist/storage/database.js +781 -37
  194. package/dist/storage/foreign-key-transaction.js +39 -0
  195. package/dist/storage/immutable-input.js +45 -0
  196. package/dist/storage/import-guard.js +102 -0
  197. package/dist/storage/native-cleanup.js +52 -0
  198. package/dist/storage/paths.js +17 -4
  199. package/dist/storage/projection-lock.js +73 -0
  200. package/dist/storage/work-references.js +23 -0
  201. package/dist/storage/writer-contract.js +77 -0
  202. package/dist/storage/writer-migration.js +85 -0
  203. package/package.json +30 -13
  204. package/tools/repair-paused-run-status.mjs +84 -0
@@ -1,46 +1,276 @@
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, errorRecord } 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
+ }
11
+ export function assertManagedProcessAdmission(context, record) {
12
+ const expiry = Date.parse(record.lease_expires_at);
13
+ if (!Number.isFinite(expiry))
14
+ throw new AppError("runtime_artifact_invalid", "Managed lease expiry is malformed", 1, { process_id: record.id });
15
+ if (expiry <= Date.parse(context.now()))
16
+ throw new AppError("process_ownership_unknown", "Managed owner needs fresh renewal before admission", 1, { process_id: record.id, reason: "renewal_required", lease_expires_at: record.lease_expires_at });
17
+ }
5
18
  const defaultLeaseMs = 15 * 60_000;
6
19
  export function resourceHome(context) {
7
20
  return context.env?.DD_FLOW_RESOURCE_HOME ?? context.ddFlowHome ?? "/tmp/dd-flow-runtime";
8
21
  }
9
- export function registerManagedProcess(context, input) {
22
+ /** Runtime IDs are home-local; a shared host registry needs this extra scope. */
23
+ export function managedProcessRuntimeHome(record, authority) {
24
+ const metadata = parseMetadata(record.metadata_json);
25
+ if (metadata.dd_flow_home !== undefined)
26
+ return typeof metadata.dd_flow_home === "string" && path.isAbsolute(metadata.dd_flow_home) ? metadata.dd_flow_home : null;
27
+ // Older native registrations can still prove their home from the retained
28
+ // exact daemon owner; PID or project/RUN labels alone never establish it.
29
+ if (typeof metadata.state_dir === "string" && path.isAbsolute(metadata.state_dir)) {
30
+ try {
31
+ const state = JSON.parse(fs.readFileSync(path.join(metadata.state_dir, "daemon.json"), "utf8"));
32
+ const owner = state.config?.runtime_owner;
33
+ 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))
34
+ return owner.dd_flow_home;
35
+ }
36
+ catch { /* No retained native scope proof. */ }
37
+ }
38
+ 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]))
39
+ return authority.ddFlowHome;
40
+ 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]))
41
+ return authority.ddFlowHome;
42
+ return null;
43
+ }
44
+ export function prepareManagedProcessRegistration(context, input) {
45
+ requireProcessText(input.kind, "kind");
46
+ requireProcessText(input.ownerId, "owner");
47
+ for (const field of ["id", "projectId", "runId", "workId", "checkId", "operationId", "stdoutPath", "stderrPath"]) {
48
+ if (input[field] !== undefined && input[field] !== null)
49
+ requireProcessText(input[field], field);
50
+ }
51
+ for (const field of ["pid", "ownerPid", "processGroupId"]) {
52
+ if (input[field] !== undefined)
53
+ requireProcessPid(input[field], field);
54
+ }
55
+ if (input.uniqueActiveOperation !== undefined && typeof input.uniqueActiveOperation !== "boolean")
56
+ throw new AppError("validation", "uniqueActiveOperation must be boolean", 2);
57
+ if (input.metadata !== undefined && (!input.metadata || typeof input.metadata !== "object" || Array.isArray(input.metadata)))
58
+ throw new AppError("validation", "metadata must be a JSON object", 2);
59
+ prepareManagedProcessLease(context, input.leaseMs);
60
+ const observer = input.kind === "eval-observer";
61
+ const observerBirth = observer && input.pid ? processStartedAt(input.pid) : null;
62
+ if (observer) {
63
+ if (!Number.isSafeInteger(input.pid) || !input.pid || input.pid < 1 || input.ownerPid !== undefined && input.ownerPid !== input.pid || input.processGroupId !== undefined && input.processGroupId !== input.pid)
64
+ throw new AppError("process_ownership_unknown", "EVAL observer registration requires its live self-owned process identity", 1);
65
+ if (!observerBirth)
66
+ throw new AppError("process_ownership_unknown", "EVAL observer physical identity observation is unavailable", 1, { process_id: input.id, reason: "physical_identity_unavailable", phase: "observer_registration", effect: "no_effect", native_dispatch_started: false });
67
+ input = { ...input, ownerPid: input.pid, uniqueActiveOperation: true };
68
+ }
69
+ if (input.uniqueActiveOperation && !input.operationId)
70
+ throw new AppError("validation", "Unique managed operation requires operationId", 2);
71
+ let metadataJson;
72
+ try {
73
+ const metadata = { ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) };
74
+ delete metadata.process_birth;
75
+ delete metadata.owner_birth;
76
+ if (input.ownerPid)
77
+ metadata.owner_birth = stableProcessBirth(input.ownerPid);
78
+ metadataJson = JSON.stringify(metadata);
79
+ }
80
+ catch {
81
+ throw new AppError("validation", "metadata must be JSON-serializable", 2);
82
+ }
83
+ const reader = getResourceDatabase(resourceHome(context), "read_existing", context.databaseDeadlineAt);
84
+ try {
85
+ if (input.id && reader.get("SELECT 1 FROM managed_processes WHERE id = ?", [input.id]))
86
+ throw new AppError("validation", "Managed process ID already exists; use its retained registration", 2, { process_id: input.id });
87
+ if (input.uniqueActiveOperation) {
88
+ const existing = reader.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
89
+ if (existing)
90
+ 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 });
91
+ }
92
+ }
93
+ finally {
94
+ reader.close?.();
95
+ }
96
+ return { input, observer, observerBirth, metadataJson };
97
+ }
98
+ export function registerManagedProcess(context, rawInput) {
99
+ const { input, observer, observerBirth, metadataJson } = prepareManagedProcessRegistration(context, rawInput);
100
+ if (!input.uniqueActiveOperation)
101
+ return registry(context).writeTransaction(() => insertManagedProcess(context, input, metadataJson));
102
+ const observerProcessBirth = observer ? stableProcessBirth(input.pid) : null;
103
+ const observerOwner = observer ? parseMetadata(metadataJson) : null;
104
+ if (observer && (!observerProcessBirth || !observerOwner?.owner_pid_started_at || !parseBirth(observerOwner.owner_birth)))
105
+ throw new AppError("process_ownership_unknown", "EVAL observer physical identity observation is unavailable", 1, { process_id: input.id, reason: "physical_identity_unavailable", phase: "observer_registration", effect: "no_effect", native_dispatch_started: false });
106
+ if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth, metadata_json: JSON.stringify({ process_birth: observerProcessBirth }) }))
107
+ throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
108
+ const db = registry(context);
109
+ return db.writeTransaction(() => {
110
+ const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
111
+ if (existing)
112
+ 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 });
113
+ let result = insertManagedProcess(context, input, metadataJson);
114
+ if (observer) {
115
+ const metadata = JSON.parse(metadataJson);
116
+ if (input.processGroupId)
117
+ metadata.process_group_id = input.processGroupId;
118
+ metadata.process_birth = observerProcessBirth;
119
+ const now = context.now();
120
+ db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
121
+ result = requireProcess(db, result.id);
122
+ }
123
+ return result;
124
+ });
125
+ }
126
+ function insertManagedProcess(context, input, metadataJson) {
10
127
  const db = registry(context);
11
128
  const now = context.now();
12
129
  const id = input.id ?? `PROC-${crypto.randomUUID()}`;
13
130
  const token = crypto.randomUUID();
14
131
  db.run(`INSERT INTO managed_processes
15
132
  (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 ?? {})]);
133
+ 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, metadataJson]);
17
134
  return requireProcess(db, id);
18
135
  }
136
+ export function prepareManagedProcessConfirmation(context, input) {
137
+ requireProcessText(input.id, "id");
138
+ requireProcessText(input.leaseToken, "lease-token");
139
+ requireProcessPid(input.pid, "pid");
140
+ if (input.ownerPid !== undefined)
141
+ requireProcessPid(input.ownerPid, "owner-pid");
142
+ if (input.processGroupId !== undefined && input.processGroupId !== null)
143
+ requireProcessPid(input.processGroupId, "process-group-id");
144
+ prepareManagedProcessLease(context, input.leaseMs);
145
+ const current = readManagedProcess(context, input.id);
146
+ if (current.lease_token !== input.leaseToken)
147
+ throw new AppError("process_lease_lost", "Managed process lease does not match", 1);
148
+ if (current.state !== "starting" && !(current.state === "running" && current.pid === input.pid && processIsAlive(current) && (!input.processGroupId || parseMetadata(current.metadata_json).process_group_id === input.processGroupId) && (!input.ownerPid || parseMetadata(current.metadata_json).owner_pid === input.ownerPid)))
149
+ throw new AppError("process_confirmation_rejected", "Only the retained physical process may be confirmed", 1, { process_id: current.id, state: current.state });
150
+ return current;
151
+ }
19
152
  export function confirmManagedProcess(context, input) {
153
+ const prepared = prepareManagedProcessConfirmation(context, input);
154
+ const retainedIdentity = parseMetadata(prepared.metadata_json);
155
+ const retainedOwner = retainedIdentity.owner_pid;
156
+ const validRetainedOwner = typeof retainedOwner === "number" && Number.isSafeInteger(retainedOwner) && retainedOwner > 0;
157
+ const ownerUnknown = Object.hasOwn(retainedIdentity, "owner_pid") && (!validRetainedOwner || typeof retainedIdentity.owner_pid_started_at !== "string" || !retainedIdentity.owner_pid_started_at.trim() || Object.hasOwn(retainedIdentity, "owner_birth") && !parseBirth(retainedIdentity.owner_birth));
158
+ if (prepared.state === "running" && (!prepared.pid_started_at || Object.hasOwn(retainedIdentity, "process_birth") && !parseBirth(retainedIdentity.process_birth) || ownerUnknown) || input.ownerPid === undefined && Object.hasOwn(retainedIdentity, "owner_pid") && !validRetainedOwner)
159
+ throw new AppError("process_ownership_unknown", "Retained managed physical identity is unconfirmed", 1, { process_id: input.id, reason: "stored_identity_unconfirmed", phase: "process_confirmation", effect: "no_effect", native_dispatch_started: false });
160
+ // Initial confirmation must observe the registered owner too, even when a
161
+ // bootstrap caller omits its optional override. Running bindings never repair.
162
+ const ownerPid = input.ownerPid ?? (validRetainedOwner ? retainedOwner : undefined);
163
+ const pidStartedAt = processStartedAt(input.pid), processBirth = stableProcessBirth(input.pid);
164
+ const ownerStartedAt = ownerPid ? processStartedAt(ownerPid) : null, ownerBirth = ownerPid ? stableProcessBirth(ownerPid) : null;
165
+ if (!pidStartedAt || !processBirth || ownerPid && (!ownerStartedAt || !ownerBirth))
166
+ throw new AppError("process_ownership_unknown", "Managed physical identity observation is unavailable", 1, { process_id: input.id, reason: "physical_identity_unavailable", phase: "process_confirmation", effect: "no_effect", native_dispatch_started: false });
20
167
  const db = registry(context);
21
- const now = context.now();
22
- const current = requireProcess(db, input.id);
23
- const metadata = parseMetadata(current.metadata_json);
24
- if (input.processGroupId)
25
- metadata.process_group_id = input.processGroupId;
26
- const result = db.run(`UPDATE managed_processes
168
+ return db.writeTransaction(() => {
169
+ const current = requireProcess(db, input.id);
170
+ if (current.lease_token !== input.leaseToken || !["starting", "running"].includes(current.state))
171
+ throw new AppError("process_confirmation_rejected", "Managed process changed before confirmation", 1);
172
+ if (current.state === "running") {
173
+ if (prepared.state !== "running" || managedProcessBinding(current, resourceHome(context)).registration_sha256 !== managedProcessBinding(prepared, resourceHome(context)).registration_sha256 || current.pid !== input.pid || input.processGroupId && parseMetadata(current.metadata_json).process_group_id !== input.processGroupId || input.ownerPid && parseMetadata(current.metadata_json).owner_pid !== input.ownerPid)
174
+ throw new AppError("process_confirmation_rejected", "Confirmed process binding changed", 1);
175
+ return { ...current, registration_sha256: managedProcessBinding(current, resourceHome(context)).registration_sha256 };
176
+ }
177
+ const now = context.now();
178
+ const metadata = parseMetadata(current.metadata_json);
179
+ if (input.processGroupId)
180
+ metadata.process_group_id = input.processGroupId;
181
+ metadata.process_birth = processBirth;
182
+ if (ownerPid) {
183
+ metadata.owner_pid = ownerPid;
184
+ metadata.owner_pid_started_at = ownerStartedAt;
185
+ metadata.owner_birth = ownerBirth;
186
+ }
187
+ const result = db.run(`UPDATE managed_processes
27
188
  SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
28
- 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]);
29
- if (result.changes !== 1)
30
- throw new Error(`Managed process cannot be confirmed: ${input.id}`);
31
- return requireProcess(db, input.id);
189
+ WHERE id = ? AND lease_token = ? AND state = 'starting'`, [input.pid, pidStartedAt, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), input.id, input.leaseToken]);
190
+ if (result.changes !== 1)
191
+ throw new Error(`Managed process cannot be confirmed: ${input.id}`);
192
+ const confirmed = requireProcess(db, input.id);
193
+ return { ...confirmed, registration_sha256: managedProcessBinding(confirmed, resourceHome(context)).registration_sha256 };
194
+ });
195
+ }
196
+ export function prepareManagedProcessHeartbeat(context, input) {
197
+ requireProcessText(input.id, "id");
198
+ requireProcessText(input.leaseToken, "lease-token");
199
+ prepareManagedProcessLease(context, input.leaseMs);
32
200
  }
33
201
  export function heartbeatManagedProcess(context, input) {
202
+ return renewManagedProcess(context, input).owned;
203
+ }
204
+ export function renewManagedProcess(context, input) {
205
+ prepareManagedProcessHeartbeat(context, input);
206
+ if (context.resourceDatabaseMode === "prepared_existing")
207
+ registry(context);
208
+ const reader = getResourceDatabase(resourceHome(context), "read_existing", context.databaseDeadlineAt);
209
+ try {
210
+ if (!reader.get("SELECT 1 FROM managed_processes WHERE id=? AND lease_token=? AND state IN ('starting','running','stopping')", [input.id, input.leaseToken]))
211
+ return { owned: false, process_id: input.id };
212
+ }
213
+ finally {
214
+ reader.close?.();
215
+ }
34
216
  const db = registry(context);
35
- return db.run("UPDATE managed_processes SET lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [leaseExpiry(context.now(), input.leaseMs), context.now(), input.id, input.leaseToken]).changes === 1;
217
+ return db.writeTransaction(() => {
218
+ const now = context.now();
219
+ if (!db.get("SELECT 1 FROM managed_processes WHERE id=? AND lease_token=? AND state IN ('starting','running','stopping')", [input.id, input.leaseToken]))
220
+ return { owned: false, process_id: input.id };
221
+ const changed = db.run("UPDATE managed_processes SET lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [leaseExpiry(now, input.leaseMs), now, input.id, input.leaseToken]).changes === 1;
222
+ if (!changed)
223
+ return { owned: false, process_id: input.id };
224
+ const record = requireProcess(db, input.id);
225
+ return { owned: true, process_id: record.id, lease_expires_at: record.lease_expires_at, registration_sha256: managedProcessBinding(record, resourceHome(context)).registration_sha256 };
226
+ });
227
+ }
228
+ /** Read-only lease check for a caller already holding a RUN writer. Renewal
229
+ * must happen before entering that writer, never from this assertion. */
230
+ export function assertManagedProcessLease(context, input) {
231
+ const reader = getResourceDatabase(resourceHome(context), "read_isolated", context.databaseDeadlineAt);
232
+ try {
233
+ const current = reader.get("SELECT pid, lease_expires_at, state FROM managed_processes WHERE id = ? AND lease_token = ?", [input.id, input.leaseToken]);
234
+ if (!current || current.pid !== input.pid || !["starting", "running"].includes(current.state)) {
235
+ throw new AppError("controller_lease_lost", "Managed owner lease changed before productive dispatch", 1, { process_id: input.id });
236
+ }
237
+ if (!Number.isFinite(Date.parse(current.lease_expires_at)) || Date.parse(current.lease_expires_at) <= Date.now())
238
+ throw new AppError("controller_lease_unconfirmed", "Managed owner requires renewal before productive dispatch", 1, { process_id: input.id, reason: "renewal_required" });
239
+ }
240
+ finally {
241
+ reader.close?.();
242
+ }
243
+ }
244
+ export function prepareManagedProcessFinish(context, input) {
245
+ requireProcessText(input.id, "id");
246
+ requireProcessText(input.leaseToken, "lease-token");
247
+ if (input.state !== "stopped" && input.state !== "failed")
248
+ throw new AppError("validation", "--state must be stopped or failed", 2);
249
+ if (input.reason !== undefined && input.reason !== null && typeof input.reason !== "string")
250
+ throw new AppError("validation", "reason must be a string or null", 2);
251
+ return readManagedProcess(context, input.id);
36
252
  }
37
253
  export function finishManagedProcess(context, input) {
254
+ prepareManagedProcessFinish(context, input);
38
255
  const db = registry(context);
39
- const now = context.now();
40
- 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
- if (updated)
42
- db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
43
- return updated;
256
+ return db.writeTransaction(() => {
257
+ const existing = requireProcess(db, input.id);
258
+ if (existing.lease_token !== input.leaseToken || existing.state !== input.state && !["starting", "running", "stopping", "orphaned"].includes(existing.state))
259
+ return false;
260
+ if (existing.state === input.state) {
261
+ db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
262
+ return true;
263
+ }
264
+ const now = context.now();
265
+ 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;
266
+ if (updated)
267
+ db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
268
+ if (updated)
269
+ return true;
270
+ // Another client may have completed the same lease after our initial read.
271
+ const current = requireProcess(db, input.id);
272
+ return current.lease_token === input.leaseToken && current.state === input.state;
273
+ });
44
274
  }
45
275
  export function processIsAlive(record) {
46
276
  if (!record.pid)
@@ -48,26 +278,49 @@ export function processIsAlive(record) {
48
278
  try {
49
279
  process.kill(record.pid, 0);
50
280
  }
51
- catch {
52
- return false;
281
+ catch (error) {
282
+ return error.code !== "ESRCH";
283
+ }
284
+ const metadata = record.metadata_json ? parseMetadata(record.metadata_json) : {};
285
+ if (Object.hasOwn(metadata, "process_birth")) {
286
+ const birth = parseBirth(metadata.process_birth), observed = stableProcessBirth(record.pid);
287
+ return !birth || !observed || observed === birth;
53
288
  }
54
- return !record.pid_started_at || record.pid_started_at === processStartedAt(record.pid);
289
+ const started = processStartedAt(record.pid);
290
+ return !record.pid_started_at || !started || record.pid_started_at === started;
55
291
  }
56
- function processTreeIsAlive(record) {
292
+ export function processTreeIsAlive(record) {
57
293
  if (processIsAlive(record))
58
294
  return true;
59
295
  const group = parseMetadata(record.metadata_json).process_group_id;
296
+ const birth = parseBirth(parseMetadata(record.metadata_json).process_birth);
297
+ // POSIX cannot reuse a leader PID while its old process group survives.
298
+ if (process.platform !== "win32" && group === record.pid && record.pid && birth) {
299
+ try {
300
+ process.kill(record.pid, 0);
301
+ }
302
+ catch { /* A missing leader can still have living children. */ }
303
+ const observed = stableProcessBirth(record.pid);
304
+ if (observed && observed !== birth)
305
+ return false;
306
+ }
60
307
  if (process.platform !== "win32" && typeof group === "number") {
61
308
  try {
62
309
  process.kill(-group, 0);
63
310
  return true;
64
311
  }
65
- catch {
66
- return false;
312
+ catch (error) {
313
+ return error.code !== "ESRCH";
67
314
  }
68
315
  }
69
316
  return false;
70
317
  }
318
+ export function managedOwnerIsAlive(record) {
319
+ const metadata = parseMetadata(record.metadata_json);
320
+ if (typeof metadata.owner_pid !== "number")
321
+ return null;
322
+ return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null, metadata_json: JSON.stringify({ process_birth: metadata.owner_birth }) });
323
+ }
71
324
  /**
72
325
  * Expiry proves that observation stopped, not that the owner died. A live PID
73
326
  * remains owned until its result is reconciled; only a dead process can be
@@ -76,31 +329,93 @@ function processTreeIsAlive(record) {
76
329
  export function claimExpiredManagedProcesses(context, ownerId) {
77
330
  const db = registry(context);
78
331
  const now = context.now();
79
- db.exec("BEGIN IMMEDIATE");
80
- try {
81
- 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]);
332
+ const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]).filter(candidate => {
333
+ const ownerAlive = managedOwnerIsAlive(candidate);
334
+ return ownerAlive !== true && !(ownerAlive === null && processTreeIsAlive(candidate));
335
+ });
336
+ return db.writeTransaction(() => {
82
337
  const claimed = [];
83
338
  for (const candidate of candidates) {
84
- if (processTreeIsAlive(candidate))
339
+ // A heartbeat or replacement registration invalidates the pre-writer
340
+ // death proof; expiry alone never authorizes claiming that new owner.
341
+ if (JSON.stringify(db.get("SELECT * FROM managed_processes WHERE id = ?", [candidate.id])) !== JSON.stringify(candidate))
85
342
  continue;
86
343
  const token = crypto.randomUUID();
87
344
  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]);
88
345
  if (result.changes === 1)
89
346
  claimed.push({ ...candidate, owner_id: ownerId, lease_token: token, state: "orphaned", updated_at: now });
90
347
  }
91
- db.exec("COMMIT");
92
348
  return claimed;
349
+ });
350
+ }
351
+ /** Workspace gates are leases, not spawned processes. Children retain their own registrations. */
352
+ export function managedLeaseReleased(record) {
353
+ const metadata = parseMetadata(record.metadata_json);
354
+ return record.kind === "workspace-gate" && record.pid === null && record.pid_started_at === null
355
+ && metadata.process_group_id == null && metadata.role === "check_gate"
356
+ && ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at);
357
+ }
358
+ export function managedProcessStatus(context) {
359
+ const db = getResourceDatabase(resourceHome(context), "read_existing", context.databaseDeadlineAt);
360
+ try {
361
+ return db.all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
93
362
  }
94
- catch (error) {
95
- db.exec("ROLLBACK");
96
- throw error;
363
+ finally {
364
+ db.close?.();
97
365
  }
98
366
  }
99
- export function managedProcessStatus(context) {
100
- return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
367
+ export function prepareManagedProcessStopOperation(context, input) {
368
+ const graceMs = prepareManagedProcessStop(input);
369
+ const record = readManagedProcess(context, input.id);
370
+ assertStopOwnership(record, input.leaseToken);
371
+ return { record, graceMs };
372
+ }
373
+ function readManagedProcess(context, id) {
374
+ const db = getResourceDatabase(resourceHome(context), "read_existing", context.databaseDeadlineAt);
375
+ try {
376
+ return requireProcess(db, id);
377
+ }
378
+ finally {
379
+ db.close?.();
380
+ }
381
+ }
382
+ function assertStopOwnership(record, leaseToken) {
383
+ if (record.lease_token !== leaseToken)
384
+ throw new AppError("process_lease_lost", "Managed process lease does not match", 1);
385
+ if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
386
+ return;
387
+ if (!record.pid || !record.pid_started_at)
388
+ throw new AppError("process_identity_unknown", "Managed process has no confirmed physical identity; retain its pending launch", 1);
389
+ }
390
+ export async function stopManagedProcess(context, input) {
391
+ const { graceMs } = prepareManagedProcessStopOperation(context, input);
392
+ const db = registry(context), record = requireProcess(db, input.id);
393
+ assertStopOwnership(record, input.leaseToken);
394
+ if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
395
+ return record;
396
+ 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]);
397
+ if (processTreeIsAlive(record)) {
398
+ const signalErrors = [];
399
+ const termError = terminateOwnedProcess(record, "SIGTERM");
400
+ if (termError)
401
+ signalErrors.push(errorRecord(termError));
402
+ await delay(graceMs);
403
+ if (!termError && input.force !== false && processTreeIsAlive(record)) {
404
+ const killError = terminateOwnedProcess(record, "SIGKILL");
405
+ if (killError)
406
+ signalErrors.push(errorRecord(killError));
407
+ await delay(250);
408
+ }
409
+ if (processTreeIsAlive(record))
410
+ 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, signal_errors: signalErrors } });
411
+ }
412
+ if (!finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" }))
413
+ throw Object.assign(new Error("Managed process ownership changed before settlement"), { code: "process_lease_lost" });
414
+ return requireProcess(db, record.id);
101
415
  }
102
416
  /** Reconcile only records the system owns and has atomically claimed. */
103
417
  export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
418
+ prepareManagedProcessReconcile(ownerId, graceMs);
104
419
  const claimed = claimExpiredManagedProcesses(context, ownerId);
105
420
  const outcomes = [];
106
421
  for (const processRecord of claimed) {
@@ -109,22 +424,46 @@ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs
109
424
  outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
110
425
  continue;
111
426
  }
112
- terminateOwnedProcess(processRecord, "SIGTERM");
113
- await delay(graceMs);
114
- if (processTreeIsAlive(processRecord)) {
115
- terminateOwnedProcess(processRecord, "SIGKILL");
116
- await delay(Math.min(graceMs, 250));
427
+ try {
428
+ await stopManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, graceMs, reason: "orphan_reconciled" });
429
+ outcomes.push({ id: processRecord.id, outcome: "stopped" });
117
430
  }
118
- if (processTreeIsAlive(processRecord)) {
431
+ catch (error) {
432
+ if (error.code !== "process_tree_not_settled")
433
+ throw error;
119
434
  outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
120
- continue;
121
435
  }
122
- finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
123
- outcomes.push({ id: processRecord.id, outcome: "stopped" });
124
436
  }
125
437
  return outcomes;
126
438
  }
439
+ export function prepareManagedProcessStop(input) {
440
+ requireProcessText(input.id, "id");
441
+ requireProcessText(input.leaseToken, "lease-token");
442
+ if (input.reason !== undefined)
443
+ requireProcessText(input.reason, "reason");
444
+ if (input.force !== undefined && typeof input.force !== "boolean")
445
+ throw new AppError("validation", "force must be boolean", 2);
446
+ return prepareProcessGrace(input.graceMs);
447
+ }
448
+ export function prepareManagedProcessReconcile(ownerId, graceMs) {
449
+ requireProcessText(ownerId, "owner");
450
+ return prepareProcessGrace(graceMs);
451
+ }
452
+ function requireProcessText(value, parameter) {
453
+ if (typeof value !== "string" || !value.trim())
454
+ throw new AppError("validation", `--${parameter} must be nonempty text`, 2);
455
+ }
456
+ function requireProcessPid(value, parameter) {
457
+ if (!Number.isSafeInteger(value) || Number(value) <= 0)
458
+ throw new AppError("validation", `--${parameter} must be a positive integer`, 2);
459
+ }
460
+ function prepareProcessGrace(value = 1_000) {
461
+ if (!Number.isSafeInteger(value) || value < 0 || value > 2 ** 31 - 1)
462
+ throw new AppError("validation", "--grace-ms must be a nonnegative integer within the supported timer range", 2);
463
+ return value;
464
+ }
127
465
  export async function reservePorts(context, input) {
466
+ prepareManagedProcessLease(context, input.leaseMs);
128
467
  const db = registry(context);
129
468
  const claims = [];
130
469
  const ports = {};
@@ -150,23 +489,42 @@ export async function reservePorts(context, input) {
150
489
  }
151
490
  return { ports, release: () => releasePortClaims(db, claims) };
152
491
  }
153
- function registry(context) { return getResourceDatabase(resourceHome(context)); }
492
+ function registry(context) { return getResourceDatabase(resourceHome(context), context.resourceDatabaseMode, context.databaseDeadlineAt); }
154
493
  function requireProcess(db, id) { const row = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]); if (!row)
155
- throw new Error(`Managed process is missing: ${id}`); return row; }
156
- function leaseExpiry(now, leaseMs = defaultLeaseMs) { return new Date(Date.parse(now) + leaseMs).toISOString(); }
157
- 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; }
494
+ throw new AppError("not_found", `Managed process is missing: ${id}`, 1); return row; }
495
+ export function prepareManagedProcessLease(context, leaseMs) { return leaseExpiry(context.now(), leaseMs); }
496
+ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
497
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || !Number.isFinite(new Date(Date.parse(now) + leaseMs).getTime()))
498
+ throw new AppError("validation", "--lease-ms must be a positive integer within the supported date range", 2);
499
+ return new Date(Date.parse(now) + leaseMs).toISOString();
500
+ }
501
+ export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
502
+ function stableProcessBirth(pid) {
503
+ const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" } });
504
+ const value = result.status === 0 ? result.stdout.trim() : "";
505
+ return value ? `ps-lstart-utc-c@1:${value}` : null;
506
+ }
507
+ function parseBirth(value) { return typeof value === "string" && value.startsWith("ps-lstart-utc-c@1:") && value.length > "ps-lstart-utc-c@1:".length ? value : null; }
158
508
  function releasePortClaims(db, claims) { for (const claim of claims)
159
509
  db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
160
510
  function terminateOwnedProcess(record, signal) {
161
511
  // Never signal a group merely because its former leader PID was once ours.
162
512
  // PID reuse or a departed leader makes group ownership unprovable.
163
- if (!record.pid || !record.pid_started_at || !processIsAlive(record))
513
+ if (!record.pid || !record.pid_started_at)
164
514
  return;
165
- const group = parseMetadata(record.metadata_json).process_group_id;
515
+ const metadata = parseMetadata(record.metadata_json);
516
+ const birth = parseBirth(metadata.process_birth);
517
+ if (Object.hasOwn(metadata, "process_birth") ? !birth || stableProcessBirth(record.pid) !== birth : processStartedAt(record.pid) !== record.pid_started_at)
518
+ return;
519
+ const group = metadata.process_group_id;
520
+ // A non-leader PID does not establish ownership of its whole process group.
166
521
  try {
167
- process.kill(process.platform === "win32" || typeof group !== "number" ? record.pid : -group, signal);
522
+ process.kill(process.platform === "win32" || group !== record.pid ? record.pid : -group, signal);
523
+ }
524
+ catch (error) {
525
+ if (error.code !== "ESRCH")
526
+ return Object.assign(error, { details: { process_id: record.id, pid: record.pid, pgid: group ?? null, signal, phase: "owned_process_stop" } });
168
527
  }
169
- catch { /* inspect again below */ }
170
528
  }
171
529
  function parseMetadata(value) { try {
172
530
  const parsed = JSON.parse(value);