@deksden-com/dd-flow-cli 0.9.0-beta.11 → 0.9.0-beta.110

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 (162) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +68 -0
  3. package/dist/build-info.json +8 -8
  4. package/dist/cli/command-inputs.js +305 -0
  5. package/dist/cli/help.js +89 -13
  6. package/dist/cli/hook-ingress.js +77 -0
  7. package/dist/cli/input-preparation.js +116 -0
  8. package/dist/cli/native-hook-ingress.js +48 -0
  9. package/dist/cli/run-cli.js +1465 -384
  10. package/dist/cli.js +8 -2
  11. package/dist/harness-runtime/DD-ZCODE.md +99 -0
  12. package/dist/harness-runtime/bin/dd-agy.mjs +52 -0
  13. package/dist/harness-runtime/bin/dd-codex.mjs +26 -0
  14. package/dist/harness-runtime/bin/dd-droid.mjs +33 -0
  15. package/dist/harness-runtime/bin/dd-grok.mjs +30 -0
  16. package/dist/harness-runtime/bin/dd-opencode.mjs +21 -0
  17. package/dist/harness-runtime/bin/dd-zcode.mjs +83 -0
  18. package/dist/harness-runtime/lib/agy-hooks.d.mts +1 -0
  19. package/dist/harness-runtime/lib/agy-hooks.mjs +9 -0
  20. package/dist/harness-runtime/lib/daemon-operations.mjs +226 -0
  21. package/dist/harness-runtime/lib/dd-agy-daemon.d.mts +6 -0
  22. package/dist/harness-runtime/lib/dd-agy-daemon.mjs +500 -0
  23. package/dist/harness-runtime/lib/dd-agy.mjs +59 -0
  24. package/dist/harness-runtime/lib/dd-codex-daemon.d.mts +1 -0
  25. package/dist/harness-runtime/lib/dd-codex-daemon.mjs +149 -0
  26. package/dist/harness-runtime/lib/dd-codex.mjs +563 -0
  27. package/dist/harness-runtime/lib/dd-droid-daemon.mjs +125 -0
  28. package/dist/harness-runtime/lib/dd-droid.mjs +475 -0
  29. package/dist/harness-runtime/lib/dd-grok-daemon.mjs +388 -0
  30. package/dist/harness-runtime/lib/dd-grok.mjs +174 -0
  31. package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +219 -0
  32. package/dist/harness-runtime/lib/dd-opencode.mjs +98 -0
  33. package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +645 -0
  34. package/dist/harness-runtime/lib/dd-zcode.mjs +982 -0
  35. package/dist/harness-runtime/lib/delegation-instructions.d.mts +13 -0
  36. package/dist/harness-runtime/lib/delegation-instructions.mjs +144 -0
  37. package/dist/harness-runtime/lib/dispatch-fence.mjs +14 -0
  38. package/dist/harness-runtime/lib/driver-recovery.mjs +129 -0
  39. package/dist/harness-runtime/lib/droid-observation.mjs +66 -0
  40. package/dist/harness-runtime/lib/managed-daemon.mjs +327 -0
  41. package/dist/harness-runtime/lib/model-observations.mjs +77 -0
  42. package/dist/harness-runtime/lib/native-hook-command.d.mts +3 -0
  43. package/dist/harness-runtime/lib/native-hook-command.mjs +108 -0
  44. package/dist/harness-runtime/lib/observation-clock.mjs +36 -0
  45. package/dist/harness-runtime/lib/operation-context.mjs +5 -0
  46. package/dist/harness-runtime/lib/operation-errors.mjs +19 -0
  47. package/dist/harness-runtime/lib/process-json.mjs +71 -0
  48. package/dist/harness-runtime/lib/process-snapshot.mjs +10 -0
  49. package/dist/harness-runtime/lib/runner-events.mjs +200 -0
  50. package/dist/harness-runtime/lib/runner-lock.mjs +42 -0
  51. package/dist/harness-runtime/lib/session-settlement.mjs +30 -0
  52. package/dist/harness-runtime/lib/tool-observations.d.mts +17 -0
  53. package/dist/harness-runtime/lib/tool-observations.mjs +153 -0
  54. package/dist/harness-runtime/lib/zcode-usage.mjs +54 -0
  55. package/dist/protocol/local-files.js +18 -9
  56. package/dist/runtime/context.js +2 -2
  57. package/dist/schemas/agent-profile.schema.json +1 -1
  58. package/dist/schemas/code-review-result.schema.json +1 -1
  59. package/dist/schemas/code-work-batch.schema.json +3 -3
  60. package/dist/schemas/code-work-result.schema.json +3 -3
  61. package/dist/schemas/plan-review-result.schema.json +2 -2
  62. package/dist/schemas/run-control-receipt.schema.json +99 -0
  63. package/dist/schemas/run-control-request.schema.json +36 -0
  64. package/dist/schemas/vnext-protocol-plan.schema.json +2 -2
  65. package/dist/services/canon.js +11 -8
  66. package/dist/services/cleanup.js +132 -39
  67. package/dist/services/cli-operation-classifier.js +22 -24
  68. package/dist/services/code-checks.js +589 -106
  69. package/dist/services/codex-hook-delivery.js +28 -0
  70. package/dist/services/command-context.js +80 -0
  71. package/dist/services/config.js +11 -8
  72. package/dist/services/continuation-outcome.js +20 -0
  73. package/dist/services/controller-fanout.js +173 -0
  74. package/dist/services/dashboard.js +103 -31
  75. package/dist/services/engines.js +121 -76
  76. package/dist/services/eval-snapshots.js +994 -97
  77. package/dist/services/execution-policy.js +181 -0
  78. package/dist/services/external-work-launch.js +166 -0
  79. package/dist/services/harness-adapter.js +147 -24
  80. package/dist/services/harness-config.js +2 -0
  81. package/dist/services/hooks.js +702 -327
  82. package/dist/services/lanes.js +78 -65
  83. package/dist/services/lifecycle-command.js +113 -18
  84. package/dist/services/lifecycle-contract.js +30 -0
  85. package/dist/services/lifecycle-invocations.js +1379 -0
  86. package/dist/services/managed-daemon-binding.js +38 -0
  87. package/dist/services/managed-processes.js +302 -34
  88. package/dist/services/merge-queue.js +228 -149
  89. package/dist/services/merge-server.js +66 -28
  90. package/dist/services/migrations.js +37 -16
  91. package/dist/services/native-daemon-history.js +49 -0
  92. package/dist/services/native-session-control.js +39 -0
  93. package/dist/services/plan-runtime.js +26 -11
  94. package/dist/services/plans.js +28 -25
  95. package/dist/services/projects.js +20 -15
  96. package/dist/services/prompts.js +25 -16
  97. package/dist/services/protocols.js +160 -86
  98. package/dist/services/recovery-observation-budget.js +61 -0
  99. package/dist/services/recovery-snapshot-database.js +142 -0
  100. package/dist/services/repair-intents.js +40 -0
  101. package/dist/services/review-copy.js +136 -0
  102. package/dist/services/run-control-receipt.js +56 -0
  103. package/dist/services/run-control-worker.js +351 -0
  104. package/dist/services/run-control.js +888 -0
  105. package/dist/services/run-controller-adapter.js +231 -0
  106. package/dist/services/run-controller-capture.js +167 -0
  107. package/dist/services/run-controller-process.js +180 -0
  108. package/dist/services/run-controller-recovery.js +221 -0
  109. package/dist/services/run-controller-state.js +36 -0
  110. package/dist/services/run-controller.js +798 -0
  111. package/dist/services/run-engine-bindings.js +1 -1
  112. package/dist/services/run-fork.js +142 -0
  113. package/dist/services/run-observations.js +112 -0
  114. package/dist/services/run-projection.js +22 -64
  115. package/dist/services/run-recovery-runtime.js +80 -0
  116. package/dist/services/run-recovery.js +345 -0
  117. package/dist/services/runs.js +536 -181
  118. package/dist/services/runtime-budget.js +413 -0
  119. package/dist/services/runtime-command.js +43 -0
  120. package/dist/services/runtime-scope-capture.js +52 -0
  121. package/dist/services/runtime-scope-control.js +460 -0
  122. package/dist/services/runtime-scope-resume.js +577 -0
  123. package/dist/services/runtime-scope-stop.js +159 -0
  124. package/dist/services/runtime-scope-worker.js +242 -0
  125. package/dist/services/runtime-service.js +97 -0
  126. package/dist/services/schema-validation.js +10 -8
  127. package/dist/services/scope-writer-cleanup.js +26 -0
  128. package/dist/services/sessions.js +35 -69
  129. package/dist/services/stage-blocker.js +17 -7
  130. package/dist/services/stage-context.js +50 -19
  131. package/dist/services/stage-lifecycle.js +100 -103
  132. package/dist/services/stage-pause.js +88 -75
  133. package/dist/services/stage-report-renderer.js +116 -6
  134. package/dist/services/stage-work-graph.js +35 -0
  135. package/dist/services/usage.js +35 -98
  136. package/dist/services/vnext-code-review.js +197 -84
  137. package/dist/services/vnext-code.js +278 -251
  138. package/dist/services/vnext-execution-profile.js +5 -3
  139. package/dist/services/vnext-fanout.js +172 -17
  140. package/dist/services/vnext-merge.js +345 -111
  141. package/dist/services/vnext-plan-review.js +171 -111
  142. package/dist/services/vnext-plan.js +180 -87
  143. package/dist/services/vnext-protocolize.js +320 -74
  144. package/dist/services/vnext-specify.js +115 -77
  145. package/dist/services/work-registry.js +1001 -187
  146. package/dist/services/workspace-bootstrap.js +76 -0
  147. package/dist/services/workspace-files.js +83 -0
  148. package/dist/services/worktrees.js +88 -36
  149. package/dist/shared/entity-references.js +8 -0
  150. package/dist/shared/errors.js +12 -0
  151. package/dist/storage/database.js +692 -35
  152. package/dist/storage/foreign-key-transaction.js +39 -0
  153. package/dist/storage/immutable-input.js +45 -0
  154. package/dist/storage/import-guard.js +102 -0
  155. package/dist/storage/native-cleanup.js +52 -0
  156. package/dist/storage/paths.js +17 -4
  157. package/dist/storage/projection-lock.js +73 -0
  158. package/dist/storage/work-references.js +23 -0
  159. package/dist/storage/writer-contract.js +77 -0
  160. package/dist/storage/writer-migration.js +85 -0
  161. package/package.json +24 -13
  162. package/tools/repair-paused-run-status.mjs +84 -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,28 +1,146 @@
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
  }
9
- export function registerManagedProcess(context, input) {
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
+ }
37
+ export function prepareManagedProcessRegistration(context, input) {
38
+ requireProcessText(input.kind, "kind");
39
+ requireProcessText(input.ownerId, "owner");
40
+ for (const field of ["id", "projectId", "runId", "workId", "checkId", "operationId", "stdoutPath", "stderrPath"]) {
41
+ if (input[field] !== undefined && input[field] !== null)
42
+ requireProcessText(input[field], field);
43
+ }
44
+ for (const field of ["pid", "ownerPid", "processGroupId"]) {
45
+ if (input[field] !== undefined)
46
+ requireProcessPid(input[field], field);
47
+ }
48
+ if (input.uniqueActiveOperation !== undefined && typeof input.uniqueActiveOperation !== "boolean")
49
+ throw new AppError("validation", "uniqueActiveOperation must be boolean", 2);
50
+ if (input.metadata !== undefined && (!input.metadata || typeof input.metadata !== "object" || Array.isArray(input.metadata)))
51
+ throw new AppError("validation", "metadata must be a JSON object", 2);
52
+ prepareManagedProcessLease(context, input.leaseMs);
53
+ const observer = input.kind === "eval-observer";
54
+ const observerBirth = observer && input.pid ? processStartedAt(input.pid) : null;
55
+ if (observer) {
56
+ 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)
57
+ throw new AppError("process_ownership_unknown", "EVAL observer registration requires its live self-owned process identity", 1);
58
+ input = { ...input, ownerPid: input.pid, uniqueActiveOperation: true };
59
+ }
60
+ if (input.uniqueActiveOperation && !input.operationId)
61
+ throw new AppError("validation", "Unique managed operation requires operationId", 2);
62
+ let metadataJson;
63
+ try {
64
+ metadataJson = JSON.stringify({ ...input.metadata, dd_flow_home: context.ddFlowHome, ...(input.ownerPid ? { owner_pid: input.ownerPid, owner_pid_started_at: processStartedAt(input.ownerPid) } : {}) });
65
+ }
66
+ catch {
67
+ throw new AppError("validation", "metadata must be JSON-serializable", 2);
68
+ }
69
+ const reader = getResourceDatabase(resourceHome(context), "read_existing");
70
+ try {
71
+ if (input.id && reader.get("SELECT 1 FROM managed_processes WHERE id = ?", [input.id]))
72
+ throw new AppError("validation", "Managed process ID already exists; use its retained registration", 2, { process_id: input.id });
73
+ if (input.uniqueActiveOperation) {
74
+ const existing = reader.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
75
+ if (existing)
76
+ 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 });
77
+ }
78
+ }
79
+ finally {
80
+ reader.close?.();
81
+ }
82
+ return { input, observer, observerBirth, metadataJson };
83
+ }
84
+ export function registerManagedProcess(context, rawInput) {
85
+ const { input, observer, observerBirth, metadataJson } = prepareManagedProcessRegistration(context, rawInput);
86
+ if (!input.uniqueActiveOperation)
87
+ return insertManagedProcess(context, input, metadataJson);
88
+ const db = registry(context);
89
+ if (observer && !processIsAlive({ pid: input.pid, pid_started_at: observerBirth }))
90
+ throw new AppError("process_ownership_unknown", "EVAL observer changed before registration", 1);
91
+ return db.writeTransaction(() => {
92
+ const existing = db.get("SELECT * FROM managed_processes WHERE operation_id = ? AND state IN ('starting','running','stopping','orphaned')", [input.operationId]);
93
+ if (existing)
94
+ throw new AppError("managed_operation_in_progress", "The operation already owns a managed process; observe it instead of spawning a duplicate", 1, { process_id: existing.id, operation_id: input.operationId });
95
+ let result = insertManagedProcess(context, input, metadataJson);
96
+ if (observer) {
97
+ const metadata = JSON.parse(metadataJson);
98
+ if (input.processGroupId)
99
+ metadata.process_group_id = input.processGroupId;
100
+ const now = context.now();
101
+ db.run("UPDATE managed_processes SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ? WHERE id = ? AND lease_token = ? AND state = 'starting'", [input.pid, observerBirth, leaseExpiry(now, input.leaseMs), now, JSON.stringify(metadata), result.id, result.lease_token]);
102
+ result = requireProcess(db, result.id);
103
+ }
104
+ return result;
105
+ });
106
+ }
107
+ function insertManagedProcess(context, input, metadataJson) {
10
108
  const db = registry(context);
11
109
  const now = context.now();
12
110
  const id = input.id ?? `PROC-${crypto.randomUUID()}`;
13
111
  const token = crypto.randomUUID();
14
112
  db.run(`INSERT INTO managed_processes
15
113
  (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 ?? {})]);
114
+ 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
115
  return requireProcess(db, id);
18
116
  }
117
+ export function prepareManagedProcessConfirmation(context, input) {
118
+ requireProcessText(input.id, "id");
119
+ requireProcessText(input.leaseToken, "lease-token");
120
+ requireProcessPid(input.pid, "pid");
121
+ if (input.ownerPid !== undefined)
122
+ requireProcessPid(input.ownerPid, "owner-pid");
123
+ if (input.processGroupId !== undefined && input.processGroupId !== null)
124
+ requireProcessPid(input.processGroupId, "process-group-id");
125
+ prepareManagedProcessLease(context, input.leaseMs);
126
+ const current = readManagedProcess(context, input.id);
127
+ if (current.lease_token !== input.leaseToken)
128
+ throw new AppError("process_lease_lost", "Managed process lease does not match", 1);
129
+ if (current.state !== "starting")
130
+ throw new AppError("process_confirmation_rejected", "Only a starting managed process can be confirmed", 1, { process_id: current.id, state: current.state });
131
+ return current;
132
+ }
19
133
  export function confirmManagedProcess(context, input) {
134
+ const current = prepareManagedProcessConfirmation(context, input);
20
135
  const db = registry(context);
21
136
  const now = context.now();
22
- const current = requireProcess(db, input.id);
23
137
  const metadata = parseMetadata(current.metadata_json);
24
138
  if (input.processGroupId)
25
139
  metadata.process_group_id = input.processGroupId;
140
+ if (input.ownerPid) {
141
+ metadata.owner_pid = input.ownerPid;
142
+ metadata.owner_pid_started_at = processStartedAt(input.ownerPid);
143
+ }
26
144
  const result = db.run(`UPDATE managed_processes
27
145
  SET pid = ?, pid_started_at = ?, state = 'running', lease_expires_at = ?, updated_at = ?, metadata_json = ?
28
146
  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]);
@@ -30,17 +148,64 @@ export function confirmManagedProcess(context, input) {
30
148
  throw new Error(`Managed process cannot be confirmed: ${input.id}`);
31
149
  return requireProcess(db, input.id);
32
150
  }
151
+ export function prepareManagedProcessHeartbeat(context, input) {
152
+ requireProcessText(input.id, "id");
153
+ requireProcessText(input.leaseToken, "lease-token");
154
+ prepareManagedProcessLease(context, input.leaseMs);
155
+ }
33
156
  export function heartbeatManagedProcess(context, input) {
157
+ prepareManagedProcessHeartbeat(context, input);
158
+ const reader = getResourceDatabase(resourceHome(context), "read_existing");
159
+ try {
160
+ if (!reader.get("SELECT 1 FROM managed_processes WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [input.id, input.leaseToken]))
161
+ return false;
162
+ }
163
+ finally {
164
+ reader.close?.();
165
+ }
34
166
  const db = registry(context);
35
167
  return db.run("UPDATE managed_processes SET lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping')", [leaseExpiry(context.now(), input.leaseMs), context.now(), input.id, input.leaseToken]).changes === 1;
36
168
  }
169
+ /** Read-only lease check for a caller already holding a RUN writer. Renewal
170
+ * must happen before entering that writer, never from this assertion. */
171
+ export function assertManagedProcessLease(context, input) {
172
+ const reader = getResourceDatabase(resourceHome(context), "read_isolated");
173
+ try {
174
+ const current = reader.get("SELECT pid, lease_expires_at, state FROM managed_processes WHERE id = ? AND lease_token = ?", [input.id, input.leaseToken]);
175
+ if (!current || current.pid !== input.pid || !["starting", "running"].includes(current.state)
176
+ || Date.parse(current.lease_expires_at) <= Date.now()) {
177
+ throw new AppError("controller_lease_lost", "Managed owner lease changed or expired before productive dispatch", 1, { process_id: input.id });
178
+ }
179
+ }
180
+ finally {
181
+ reader.close?.();
182
+ }
183
+ }
184
+ export function prepareManagedProcessFinish(context, input) {
185
+ requireProcessText(input.id, "id");
186
+ requireProcessText(input.leaseToken, "lease-token");
187
+ if (input.state !== "stopped" && input.state !== "failed")
188
+ throw new AppError("validation", "--state must be stopped or failed", 2);
189
+ if (input.reason !== undefined && input.reason !== null && typeof input.reason !== "string")
190
+ throw new AppError("validation", "reason must be a string or null", 2);
191
+ return readManagedProcess(context, input.id);
192
+ }
37
193
  export function finishManagedProcess(context, input) {
194
+ const existing = prepareManagedProcessFinish(context, input);
195
+ if (existing.lease_token === input.leaseToken && existing.state === input.state)
196
+ return true;
197
+ if (existing.lease_token !== input.leaseToken || !["starting", "running", "stopping", "orphaned"].includes(existing.state))
198
+ return false;
38
199
  const db = registry(context);
39
200
  const now = context.now();
40
201
  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
202
  if (updated)
42
203
  db.run("DELETE FROM managed_resources WHERE process_id = ?", [input.id]);
43
- return updated;
204
+ if (updated)
205
+ return true;
206
+ // Another client may have completed the same lease after our initial read.
207
+ const current = requireProcess(db, input.id);
208
+ return current.lease_token === input.leaseToken && current.state === input.state;
44
209
  }
45
210
  export function processIsAlive(record) {
46
211
  if (!record.pid)
@@ -48,51 +213,123 @@ export function processIsAlive(record) {
48
213
  try {
49
214
  process.kill(record.pid, 0);
50
215
  }
51
- catch {
52
- return false;
216
+ catch (error) {
217
+ return error.code !== "ESRCH";
53
218
  }
54
- return !record.pid_started_at || record.pid_started_at === processStartedAt(record.pid);
219
+ const started = processStartedAt(record.pid);
220
+ return !record.pid_started_at || !started || record.pid_started_at === started;
55
221
  }
56
- function processTreeIsAlive(record) {
222
+ export function processTreeIsAlive(record) {
223
+ if (processIsAlive(record))
224
+ return true;
57
225
  const group = parseMetadata(record.metadata_json).process_group_id;
58
226
  if (process.platform !== "win32" && typeof group === "number") {
59
227
  try {
60
228
  process.kill(-group, 0);
61
229
  return true;
62
230
  }
63
- catch {
64
- return false;
231
+ catch (error) {
232
+ return error.code !== "ESRCH";
65
233
  }
66
234
  }
67
- return processIsAlive(record);
235
+ return false;
236
+ }
237
+ export function managedOwnerIsAlive(record) {
238
+ const metadata = parseMetadata(record.metadata_json);
239
+ if (typeof metadata.owner_pid !== "number")
240
+ return null;
241
+ return processIsAlive({ pid: metadata.owner_pid, pid_started_at: typeof metadata.owner_pid_started_at === "string" ? metadata.owner_pid_started_at : null });
68
242
  }
69
- /** Claims only expired records. Callers must still verify `processIsAlive` before stopping a PID. */
243
+ /**
244
+ * Expiry proves that observation stopped, not that the owner died. A live PID
245
+ * remains owned until its result is reconciled; only a dead process can be
246
+ * atomically claimed for cleanup.
247
+ */
70
248
  export function claimExpiredManagedProcesses(context, ownerId) {
71
249
  const db = registry(context);
72
250
  const now = context.now();
73
- db.exec("BEGIN IMMEDIATE");
74
- try {
75
- 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]);
251
+ const candidates = db.all("SELECT * FROM managed_processes WHERE state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ? ORDER BY lease_expires_at, id", [now]).filter(candidate => {
252
+ const ownerAlive = managedOwnerIsAlive(candidate);
253
+ return ownerAlive !== true && !(ownerAlive === null && processTreeIsAlive(candidate));
254
+ });
255
+ return db.writeTransaction(() => {
76
256
  const claimed = [];
77
257
  for (const candidate of candidates) {
258
+ // A heartbeat or replacement registration invalidates the pre-writer
259
+ // death proof; expiry alone never authorizes claiming that new owner.
260
+ if (JSON.stringify(db.get("SELECT * FROM managed_processes WHERE id = ?", [candidate.id])) !== JSON.stringify(candidate))
261
+ continue;
78
262
  const token = crypto.randomUUID();
79
263
  const result = db.run("UPDATE managed_processes SET owner_id = ?, lease_token = ?, state = 'orphaned', lease_expires_at = ?, updated_at = ? WHERE id = ? AND lease_token = ? AND state IN ('starting','running','stopping','orphaned') AND lease_expires_at < ?", [ownerId, token, leaseExpiry(now), now, candidate.id, candidate.lease_token, now]);
80
264
  if (result.changes === 1)
81
265
  claimed.push({ ...candidate, owner_id: ownerId, lease_token: token, state: "orphaned", updated_at: now });
82
266
  }
83
- db.exec("COMMIT");
84
267
  return claimed;
268
+ });
269
+ }
270
+ /** Workspace gates are leases, not spawned processes. Children retain their own registrations. */
271
+ export function managedLeaseReleased(record) {
272
+ const metadata = parseMetadata(record.metadata_json);
273
+ return record.kind === "workspace-gate" && record.pid === null && record.pid_started_at === null
274
+ && metadata.process_group_id == null && metadata.role === "check_gate"
275
+ && ["stopped", "failed"].includes(record.state) && Boolean(record.finished_at);
276
+ }
277
+ export function managedProcessStatus(context) {
278
+ const db = getResourceDatabase(resourceHome(context), "read_existing");
279
+ try {
280
+ return db.all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
85
281
  }
86
- catch (error) {
87
- db.exec("ROLLBACK");
88
- throw error;
282
+ finally {
283
+ db.close?.();
89
284
  }
90
285
  }
91
- export function managedProcessStatus(context) {
92
- return registry(context).all("SELECT * FROM managed_processes ORDER BY updated_at DESC, id");
286
+ export function prepareManagedProcessStopOperation(context, input) {
287
+ const graceMs = prepareManagedProcessStop(input);
288
+ const record = readManagedProcess(context, input.id);
289
+ assertStopOwnership(record, input.leaseToken);
290
+ return { record, graceMs };
291
+ }
292
+ function readManagedProcess(context, id) {
293
+ const db = getResourceDatabase(resourceHome(context), "read_existing");
294
+ try {
295
+ return requireProcess(db, id);
296
+ }
297
+ finally {
298
+ db.close?.();
299
+ }
300
+ }
301
+ function assertStopOwnership(record, leaseToken) {
302
+ if (record.lease_token !== leaseToken)
303
+ throw new AppError("process_lease_lost", "Managed process lease does not match", 1);
304
+ if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
305
+ return;
306
+ if (!record.pid || !record.pid_started_at)
307
+ throw new AppError("process_identity_unknown", "Managed process has no confirmed physical identity; retain its pending launch", 1);
308
+ }
309
+ export async function stopManagedProcess(context, input) {
310
+ const { graceMs } = prepareManagedProcessStopOperation(context, input);
311
+ const db = registry(context), record = requireProcess(db, input.id);
312
+ assertStopOwnership(record, input.leaseToken);
313
+ if (["stopped", "failed"].includes(record.state) && !processTreeIsAlive(record))
314
+ return record;
315
+ 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]);
316
+ if (processTreeIsAlive(record)) {
317
+ terminateOwnedProcess(record, "SIGTERM");
318
+ await delay(graceMs);
319
+ if (input.force !== false && processTreeIsAlive(record)) {
320
+ terminateOwnedProcess(record, "SIGKILL");
321
+ await delay(250);
322
+ }
323
+ if (processTreeIsAlive(record))
324
+ 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 } });
325
+ }
326
+ if (!finishManagedProcess(context, { id: record.id, leaseToken: record.lease_token, state: "stopped", reason: input.reason ?? "requested_stop" }))
327
+ throw Object.assign(new Error("Managed process ownership changed before settlement"), { code: "process_lease_lost" });
328
+ return requireProcess(db, record.id);
93
329
  }
94
330
  /** Reconcile only records the system owns and has atomically claimed. */
95
331
  export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs = 1_000) {
332
+ prepareManagedProcessReconcile(ownerId, graceMs);
96
333
  const claimed = claimExpiredManagedProcesses(context, ownerId);
97
334
  const outcomes = [];
98
335
  for (const processRecord of claimed) {
@@ -101,22 +338,46 @@ export async function reconcileExpiredManagedProcesses(context, ownerId, graceMs
101
338
  outcomes.push({ id: processRecord.id, outcome: "already_stopped" });
102
339
  continue;
103
340
  }
104
- terminateOwnedProcess(processRecord, "SIGTERM");
105
- await delay(graceMs);
106
- if (processTreeIsAlive(processRecord)) {
107
- terminateOwnedProcess(processRecord, "SIGKILL");
108
- await delay(Math.min(graceMs, 250));
341
+ try {
342
+ await stopManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, graceMs, reason: "orphan_reconciled" });
343
+ outcomes.push({ id: processRecord.id, outcome: "stopped" });
109
344
  }
110
- if (processTreeIsAlive(processRecord)) {
345
+ catch (error) {
346
+ if (error.code !== "process_tree_not_settled")
347
+ throw error;
111
348
  outcomes.push({ id: processRecord.id, outcome: "kill_failed" });
112
- continue;
113
349
  }
114
- finishManagedProcess(context, { id: processRecord.id, leaseToken: processRecord.lease_token, state: "stopped", reason: "orphan_reconciled" });
115
- outcomes.push({ id: processRecord.id, outcome: "stopped" });
116
350
  }
117
351
  return outcomes;
118
352
  }
353
+ export function prepareManagedProcessStop(input) {
354
+ requireProcessText(input.id, "id");
355
+ requireProcessText(input.leaseToken, "lease-token");
356
+ if (input.reason !== undefined)
357
+ requireProcessText(input.reason, "reason");
358
+ if (input.force !== undefined && typeof input.force !== "boolean")
359
+ throw new AppError("validation", "force must be boolean", 2);
360
+ return prepareProcessGrace(input.graceMs);
361
+ }
362
+ export function prepareManagedProcessReconcile(ownerId, graceMs) {
363
+ requireProcessText(ownerId, "owner");
364
+ return prepareProcessGrace(graceMs);
365
+ }
366
+ function requireProcessText(value, parameter) {
367
+ if (typeof value !== "string" || !value.trim())
368
+ throw new AppError("validation", `--${parameter} must be nonempty text`, 2);
369
+ }
370
+ function requireProcessPid(value, parameter) {
371
+ if (!Number.isSafeInteger(value) || Number(value) <= 0)
372
+ throw new AppError("validation", `--${parameter} must be a positive integer`, 2);
373
+ }
374
+ function prepareProcessGrace(value = 1_000) {
375
+ if (!Number.isSafeInteger(value) || value < 0 || value > 2 ** 31 - 1)
376
+ throw new AppError("validation", "--grace-ms must be a nonnegative integer within the supported timer range", 2);
377
+ return value;
378
+ }
119
379
  export async function reservePorts(context, input) {
380
+ prepareManagedProcessLease(context, input.leaseMs);
120
381
  const db = registry(context);
121
382
  const claims = [];
122
383
  const ports = {};
@@ -144,13 +405,20 @@ export async function reservePorts(context, input) {
144
405
  }
145
406
  function registry(context) { return getResourceDatabase(resourceHome(context)); }
146
407
  function requireProcess(db, id) { const row = db.get("SELECT * FROM managed_processes WHERE id = ?", [id]); if (!row)
147
- throw new Error(`Managed process is missing: ${id}`); return row; }
148
- 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; }
408
+ throw new AppError("not_found", `Managed process is missing: ${id}`, 1); return row; }
409
+ export function prepareManagedProcessLease(context, leaseMs) { return leaseExpiry(context.now(), leaseMs); }
410
+ function leaseExpiry(now, leaseMs = defaultLeaseMs) {
411
+ if (!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || !Number.isFinite(new Date(Date.parse(now) + leaseMs).getTime()))
412
+ throw new AppError("validation", "--lease-ms must be a positive integer within the supported date range", 2);
413
+ return new Date(Date.parse(now) + leaseMs).toISOString();
414
+ }
415
+ export function processStartedAt(pid) { const result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", timeout: 1_000 }); const value = result.status === 0 ? result.stdout.trim() : ""; return value || null; }
150
416
  function releasePortClaims(db, claims) { for (const claim of claims)
151
417
  db.run("DELETE FROM managed_resources WHERE resource_kind = 'port' AND resource_key = ? AND lease_token = ?", [claim.key, claim.token]); }
152
418
  function terminateOwnedProcess(record, signal) {
153
- if (!record.pid || !record.pid_started_at || !processTreeIsAlive(record))
419
+ // Never signal a group merely because its former leader PID was once ours.
420
+ // PID reuse or a departed leader makes group ownership unprovable.
421
+ if (!record.pid || !record.pid_started_at || processStartedAt(record.pid) !== record.pid_started_at)
154
422
  return;
155
423
  const group = parseMetadata(record.metadata_json).process_group_id;
156
424
  try {