@gajae-code/coding-agent 0.16.7 → 0.17.1

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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -2,17 +2,13 @@ import { randomBytes } from "node:crypto";
2
2
  import * as fsSync from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import path from "node:path";
5
-
6
- import type { NativeRetainedBrokerPublication } from "@gajae-code/natives";
7
-
8
- type NativeBrokerDiscoveryBindings = Pick<typeof import("@gajae-code/natives"), "retainBrokerPublication">;
9
- let nativeBrokerDiscoveryBindings: NativeBrokerDiscoveryBindings | undefined;
10
-
11
- function nativeBrokerDiscovery(): NativeBrokerDiscoveryBindings {
12
- if (!nativeBrokerDiscoveryBindings)
13
- nativeBrokerDiscoveryBindings = require("@gajae-code/natives") as NativeBrokerDiscoveryBindings;
14
- return nativeBrokerDiscoveryBindings;
15
- }
5
+ import type {
6
+ NativeBrokerRestartIntent,
7
+ NativeBrokerRestartIntentIdentity,
8
+ NativeRetainedBrokerPublication,
9
+ } from "@gajae-code/natives";
10
+ import { retainBrokerPublication as retainBrokerPublicationFn } from "@gajae-code/natives";
11
+ import { BROKER_ARTIFACT_PATHS, canonicalServiceRootDigest } from "../service-artifact-paths";
16
12
 
17
13
  import { processIncarnation } from "./process-incarnation";
18
14
  import { assertSupportedStateVersion, SDK_STATE_VERSION } from "./state-version";
@@ -23,6 +19,14 @@ export interface RetainedBrokerDiscovery {
23
19
  observeAsync(): Promise<BrokerPublicationObservation>;
24
20
  heartbeat(heartbeatAt: number): Promise<boolean>;
25
21
  close(): void;
22
+ /** Owner-side `prepared`: exclusively creates the durable restart-intent slot. */
23
+ prepareRestartIntent(intent: NativeBrokerRestartIntent): Promise<boolean>;
24
+ /** Owner-side `prepared` -> `committed`: rewrites the exact slot prepare created. */
25
+ commitRestartIntent(intent: NativeBrokerRestartIntent): Promise<boolean>;
26
+ /** Owner-side cancel of this process's own prepared/committed slot. */
27
+ cancelRestartIntent(): Promise<boolean>;
28
+ /** Successor-side clear of a predecessor's slot this process never prepared. */
29
+ clearForeignRestartIntent(identity: NativeBrokerRestartIntentIdentity): Promise<boolean>;
26
30
  }
27
31
 
28
32
  /**
@@ -216,12 +220,8 @@ function describeWithheldPublicationAuthority(agentDir: string): string | undefi
216
220
  }
217
221
 
218
222
  function requireRetainedBrokerPublication(agentDir: string): NativeRetainedBrokerPublication {
219
- const retainBrokerPublication = nativeBrokerDiscovery().retainBrokerPublication;
220
- if (typeof retainBrokerPublication !== "function") {
221
- throw new Error("Loaded native bindings do not expose retained broker publication authority.");
222
- }
223
223
  try {
224
- return retainBrokerPublication(agentDir);
224
+ return retainBrokerPublicationFn(agentDir);
225
225
  } catch (error) {
226
226
  // Fail closed exactly as before; only name the condition on the way out.
227
227
  const obstruction = nativeRetainedObstruction(error);
@@ -299,6 +299,26 @@ class NativeRetainedBrokerDiscovery implements RetainedBrokerDiscovery {
299
299
  this.#closed = true;
300
300
  this.#publication.close();
301
301
  }
302
+ async prepareRestartIntent(intent: NativeBrokerRestartIntent): Promise<boolean> {
303
+ if (this.#closed || !isValidRestartToken(intent.requestId) || !isValidRestartToken(intent.lease)) return false;
304
+ return (await this.#publication.prepareRestartIntentAsync(intent)).kind === "written";
305
+ }
306
+ async commitRestartIntent(intent: NativeBrokerRestartIntent): Promise<boolean> {
307
+ if (this.#closed || !isValidRestartToken(intent.requestId) || !isValidRestartToken(intent.lease)) return false;
308
+ return (await this.#publication.commitRestartIntentAsync(intent)).kind === "written";
309
+ }
310
+ async cancelRestartIntent(): Promise<boolean> {
311
+ if (this.#closed) return false;
312
+ return (await this.#publication.cancelRestartIntentAsync()).kind === "cancelled";
313
+ }
314
+ async clearForeignRestartIntent(identity: NativeBrokerRestartIntentIdentity): Promise<boolean> {
315
+ if (this.#closed) return false;
316
+ return (await this.#publication.clearRestartIntentAsync(identity)).kind === "cleared";
317
+ }
318
+ }
319
+
320
+ function isValidRestartToken(value: string): boolean {
321
+ return /^[A-Za-z0-9_-]{1,256}$/.test(value);
302
322
  }
303
323
 
304
324
  class LegacyWindowsBrokerDiscovery implements RetainedBrokerDiscovery {
@@ -322,6 +342,18 @@ class LegacyWindowsBrokerDiscovery implements RetainedBrokerDiscovery {
322
342
  this.#discovery = next;
323
343
  return true;
324
344
  }
345
+ async prepareRestartIntent(_intent: NativeBrokerRestartIntent): Promise<boolean> {
346
+ return false;
347
+ }
348
+ async commitRestartIntent(_intent: NativeBrokerRestartIntent): Promise<boolean> {
349
+ return false;
350
+ }
351
+ async cancelRestartIntent(): Promise<boolean> {
352
+ return false;
353
+ }
354
+ async clearForeignRestartIntent(_identity: NativeBrokerRestartIntentIdentity): Promise<boolean> {
355
+ return false;
356
+ }
325
357
  close(): void {
326
358
  this.#closed = true;
327
359
  }
@@ -336,6 +368,16 @@ export interface BrokerDiscovery {
336
368
  version: typeof SDK_STATE_VERSION;
337
369
  protocolVersion: 3;
338
370
  packageGeneration: string;
371
+ /**
372
+ * The runtime image this broker launches internal SDK processes with. Present
373
+ * whenever the publisher could classify its own runtime, including when that
374
+ * image was already gone at publication time -- absence is the reader's
375
+ * verdict to reach, not a reason to publish nothing. Absent only for records
376
+ * written before this field existed and for a publisher whose runtime
377
+ * evidence could not be classified at all; readers then reuse the broker as
378
+ * before rather than retiring it on unknown evidence.
379
+ */
380
+ runtime?: string;
339
381
  ownerId: string;
340
382
  pid: number;
341
383
  incarnation: string;
@@ -345,10 +387,18 @@ export interface BrokerDiscovery {
345
387
  token: string;
346
388
  startedAt: number;
347
389
  heartbeatAt: number;
390
+ restartRequestId?: string;
391
+ rootDigest?: string;
392
+ }
393
+ export interface BrokerRestartIntent {
394
+ requestId: string;
395
+ lease: string;
396
+ expiresAt: number;
397
+ phase?: "prepared" | "committed" | "cancelled";
348
398
  }
349
399
  export type RedactedBrokerDiscovery = Omit<BrokerDiscovery, "token"> & { token: "[redacted]" };
350
400
  export type BrokerDiscoveryWrite = Omit<BrokerDiscovery, "incarnation"> & { incarnation?: string };
351
- export const brokerDiscoveryPath = (agentDir: string) => path.join(agentDir, "sdk", "broker.json");
401
+ export const brokerDiscoveryPath = (agentDir: string) => path.join(agentDir, BROKER_ARTIFACT_PATHS.discovery);
352
402
  export const newBrokerToken = () => randomBytes(32).toString("hex");
353
403
  export const brokerProcessIncarnation = processIncarnation;
354
404
  export function isPidAlive(pid: number): boolean {
@@ -389,7 +439,6 @@ async function syncDirectory(directory: string): Promise<void> {
389
439
  export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDiscoveryWrite): Promise<void> {
390
440
  const incarnation = discovery.incarnation ?? brokerProcessIncarnation(discovery.pid);
391
441
  if (!incarnation) throw new Error(`Broker process incarnation is unavailable for pid ${discovery.pid}.`);
392
- const record: BrokerDiscovery = { ...discovery, incarnation };
393
442
  if (!isFixedWidthHeartbeat(discovery.heartbeatAt)) {
394
443
  throw new Error("Broker heartbeatAt must be a fixed-width 13-digit millisecond timestamp.");
395
444
  }
@@ -397,6 +446,11 @@ export async function writeBrokerDiscovery(agentDir: string, discovery: BrokerDi
397
446
  const dir = path.dirname(file);
398
447
  await fs.mkdir(dir, { recursive: true, mode: 0o700 });
399
448
  await fs.chmod(dir, 0o700);
449
+ const record: BrokerDiscovery = {
450
+ ...discovery,
451
+ incarnation,
452
+ rootDigest: await canonicalServiceRootDigest(agentDir),
453
+ };
400
454
  const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
401
455
  try {
402
456
  await fs.writeFile(temp, `${JSON.stringify(record)}\n`, { mode: 0o600 });
@@ -417,8 +471,12 @@ export async function publishBrokerDiscovery(
417
471
  ): Promise<RetainedBrokerDiscovery> {
418
472
  const incarnation = discovery.incarnation ?? brokerProcessIncarnation(discovery.pid);
419
473
  if (!incarnation) throw new Error(`Broker process incarnation is unavailable for pid ${discovery.pid}.`);
420
- const published: BrokerDiscovery = { ...discovery, incarnation };
421
- await writeBrokerDiscovery(agentDir, published);
474
+ await writeBrokerDiscovery(agentDir, { ...discovery, incarnation });
475
+ const published: BrokerDiscovery = {
476
+ ...discovery,
477
+ incarnation,
478
+ rootDigest: await canonicalServiceRootDigest(agentDir),
479
+ };
422
480
  if (platform === "win32") return new LegacyWindowsBrokerDiscovery(agentDir, published);
423
481
  try {
424
482
  return new NativeRetainedBrokerDiscovery(requireRetainedBrokerPublication(agentDir));
@@ -465,9 +523,11 @@ export async function readBrokerDiscovery(
465
523
  d.pid <= 0 ||
466
524
  typeof d.incarnation !== "string" ||
467
525
  d.incarnation.length === 0 ||
526
+ (d.runtime !== undefined && (typeof d.runtime !== "string" || d.runtime.length === 0)) ||
468
527
  !Number.isFinite(d.heartbeatAt)
469
528
  )
470
529
  return null;
530
+ if (d.restartRequestId !== undefined && !/^[A-Za-z0-9_-]{1,256}$/.test(d.restartRequestId)) return null;
471
531
  if (!isPidAlive(d.pid)) return null;
472
532
  const incarnation = brokerProcessIncarnation(d.pid);
473
533
  if (!incarnation || incarnation !== d.incarnation || Date.now() - d.heartbeatAt > ttlMs) return null;
@@ -477,6 +537,99 @@ export async function readBrokerDiscovery(
477
537
  throw e;
478
538
  }
479
539
  }
540
+
541
+ /**
542
+ * The restart-intent slot lives directly beneath `sdk/`, independent of
543
+ * `sdk/broker.lock/` -- so it durably survives a stale-lock reclaim that
544
+ * renames the incumbent's lock directory aside (`Broker#reclaimStaleLock`).
545
+ */
546
+ export const brokerRestartIntentPath = (agentDir: string) => path.join(agentDir, BROKER_ARTIFACT_PATHS.restartIntent);
547
+
548
+ export interface BrokerRestartIntentWithIdentity extends BrokerRestartIntent {
549
+ /** Existing platform file identity, for a successor's exact-identity clear. */
550
+ identity: NativeBrokerRestartIntentIdentity;
551
+ }
552
+
553
+ export async function readBrokerRestartIntent(agentDir: string): Promise<BrokerRestartIntentWithIdentity | null> {
554
+ const file = brokerRestartIntentPath(agentDir);
555
+ let handle: fs.FileHandle | undefined;
556
+ try {
557
+ handle = await fs.open(
558
+ file,
559
+ fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW | fsSync.constants.O_NONBLOCK,
560
+ );
561
+ const before = await handle.stat({ bigint: true });
562
+ const parent = await fs.lstat(path.dirname(file), { bigint: true });
563
+ if (
564
+ !before.isFile() ||
565
+ before.nlink !== 1n ||
566
+ before.size > 4096n ||
567
+ !parent.isDirectory() ||
568
+ parent.isSymbolicLink()
569
+ )
570
+ throw new Error("Malformed broker restart intent.");
571
+ const buffer = new Uint8Array(4097);
572
+ let length = 0;
573
+ while (length < buffer.length) {
574
+ const read = await handle.read(buffer, length, buffer.length - length, length);
575
+ if (read.bytesRead === 0) break;
576
+ length += read.bytesRead;
577
+ }
578
+ if (length > 4096) throw new Error("Malformed broker restart intent.");
579
+ const after = await handle.stat({ bigint: true });
580
+ const lexical = await fs.lstat(file, { bigint: true });
581
+ const parentAfter = await fs.lstat(path.dirname(file), { bigint: true });
582
+ if (
583
+ after.dev !== before.dev ||
584
+ after.ino !== before.ino ||
585
+ after.size !== before.size ||
586
+ after.mtimeNs !== before.mtimeNs ||
587
+ after.ctimeNs !== before.ctimeNs ||
588
+ after.nlink !== 1n ||
589
+ lexical.isSymbolicLink() ||
590
+ lexical.dev !== after.dev ||
591
+ lexical.ino !== after.ino ||
592
+ lexical.mtimeNs !== after.mtimeNs ||
593
+ lexical.ctimeNs !== after.ctimeNs ||
594
+ parentAfter.isSymbolicLink() ||
595
+ parent.dev !== parentAfter.dev ||
596
+ parent.ino !== parentAfter.ino
597
+ )
598
+ throw new Error("Broker restart intent changed during observation.");
599
+ let value: unknown;
600
+ try {
601
+ value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length)));
602
+ } catch {
603
+ throw new Error("Malformed broker restart intent.");
604
+ }
605
+ if (!value || typeof value !== "object" || Array.isArray(value))
606
+ throw new Error("Malformed broker restart intent.");
607
+ const intent = value as Partial<BrokerRestartIntent>;
608
+ if (
609
+ typeof intent.requestId !== "string" ||
610
+ !/^[A-Za-z0-9_-]{1,256}$/.test(intent.requestId) ||
611
+ typeof intent.lease !== "string" ||
612
+ !/^[A-Za-z0-9_-]{1,256}$/.test(intent.lease) ||
613
+ typeof intent.expiresAt !== "number" ||
614
+ !Number.isSafeInteger(intent.expiresAt) ||
615
+ intent.expiresAt <= 0 ||
616
+ (intent.phase !== "prepared" && intent.phase !== "committed" && intent.phase !== "cancelled")
617
+ )
618
+ throw new Error("Malformed broker restart intent.");
619
+ return {
620
+ requestId: intent.requestId,
621
+ lease: intent.lease,
622
+ expiresAt: intent.expiresAt,
623
+ phase: intent.phase,
624
+ identity: { dev: after.dev, ino: after.ino },
625
+ };
626
+ } catch (error) {
627
+ if ((error as NodeJS.ErrnoException).code === "ENOENT" && !handle) return null;
628
+ throw error;
629
+ } finally {
630
+ await handle?.close();
631
+ }
632
+ }
480
633
  export function redactBrokerDiscovery(discovery: BrokerDiscovery): RedactedBrokerDiscovery {
481
634
  return { ...discovery, token: "[redacted]" };
482
635
  }
@@ -0,0 +1,206 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SdkClient, SdkClientError } from "../client/client";
3
+ import type {
4
+ BrokerRestartCommitOptions,
5
+ BrokerRestartOwnerIdentity,
6
+ BrokerRestartPrepareOptions,
7
+ BrokerRestartPrepareResult,
8
+ BrokerRestartResult,
9
+ } from "./broker";
10
+ import { launchAuthorizedBrokerSuccessor, oldOwnerConfirmedExited } from "./daemon-entry";
11
+ import { type BrokerDiscovery, readBrokerDiscovery } from "./discovery";
12
+
13
+ export interface DoctorBrokerRestartOptions {
14
+ agentDir: string;
15
+ deadlineMs?: number;
16
+ drain?: boolean;
17
+ requestId?: string;
18
+ }
19
+
20
+ /**
21
+ * Concrete typed before/after-effect outcome of a full D8 broker restart
22
+ * attempt. Every refusal names the exact stage and, where the broker itself
23
+ * refused, the broker's own typed error code -- never an opaque thrown
24
+ * message standing in for a decision this adapter could name.
25
+ */
26
+ export type DoctorBrokerRestartOutcome =
27
+ | {
28
+ kind: "restarted";
29
+ requestId: string;
30
+ oldOwner: BrokerRestartOwnerIdentity;
31
+ successor: BrokerDiscovery;
32
+ /** True when an already-committed successor for this exact request was found and reused, not spawned. */
33
+ adopted: boolean;
34
+ }
35
+ | { kind: "owner_unavailable"; reason: "no_discovery" | "owner_not_confirmed_live" }
36
+ | { kind: "prepare_refused"; code: string; message: string }
37
+ | { kind: "commit_refused"; code: string; message: string }
38
+ | { kind: "old_owner_exit_timeout"; requestId: string; oldOwner: BrokerRestartOwnerIdentity }
39
+ | {
40
+ kind: "successor_refused";
41
+ requestId: string;
42
+ oldOwner: BrokerRestartOwnerIdentity;
43
+ reason:
44
+ | "deadline_elapsed"
45
+ | "intent_not_committed"
46
+ | "spawn_failed"
47
+ | "spawn_exited_before_publication"
48
+ | "publication_timeout";
49
+ detail?: string;
50
+ };
51
+
52
+ function identity(discovery: BrokerDiscovery): BrokerRestartOwnerIdentity {
53
+ return {
54
+ ownerId: discovery.ownerId,
55
+ generation: discovery.packageGeneration,
56
+ pid: discovery.pid,
57
+ incarnation: discovery.incarnation,
58
+ };
59
+ }
60
+
61
+ function typedResult(response: unknown): BrokerRestartResult {
62
+ if (response && typeof response === "object" && "ok" in response) return response as BrokerRestartResult;
63
+ return { ok: false, error: { code: "protocol_error", message: "broker returned a malformed restart response" } };
64
+ }
65
+
66
+ export async function prepareDoctorBrokerRestart(
67
+ client: SdkClient,
68
+ discovery: BrokerDiscovery,
69
+ options: DoctorBrokerRestartOptions & { requestId: string },
70
+ ): Promise<BrokerRestartResult> {
71
+ const input: BrokerRestartPrepareOptions = {
72
+ ...identity(discovery),
73
+ requestId: options.requestId,
74
+ deadlineAt: Date.now() + (options.deadlineMs ?? 30_000),
75
+ drain: options.drain,
76
+ };
77
+ return typedResult(await client.global("broker.prepare_restart", { ...input }));
78
+ }
79
+
80
+ export async function commitDoctorBrokerRestart(
81
+ client: SdkClient,
82
+ discovery: BrokerDiscovery,
83
+ prepared: BrokerRestartPrepareResult,
84
+ requestId: string,
85
+ ): Promise<BrokerRestartResult> {
86
+ const input: BrokerRestartCommitOptions = {
87
+ ...identity(discovery),
88
+ requestId,
89
+ deadlineAt: prepared.expiresAt,
90
+ lease: prepared.lease,
91
+ occupancyEpoch: prepared.occupancyEpoch,
92
+ };
93
+ return typedResult(await client.global("broker.commit_restart", { ...input }));
94
+ }
95
+
96
+ export async function cancelDoctorBrokerRestart(client: SdkClient, requestId: string): Promise<BrokerRestartResult> {
97
+ return typedResult(await client.global("broker.cancel_restart", { requestId }));
98
+ }
99
+
100
+ const OLD_OWNER_EXIT_POLL_MS = 50;
101
+
102
+ /**
103
+ * Owner-side prepare/commit/stop, positive-exit-confirmed cleanup, then
104
+ * exactly one successor launch or adoption. The exact sequence:
105
+ *
106
+ * 1. Read the CURRENT discovery snapshot (never a stale caller-supplied one)
107
+ * and prove its owner is live via a real connected client, not an
108
+ * incarnation string comparison alone.
109
+ * 2. `prepare_restart` against that exact owner tuple/generation; on any
110
+ * prepare refusal, cancel the reservation and return the broker's own
111
+ * typed refusal rather than throwing.
112
+ * 3. `commit_restart` with the exact lease/occupancyEpoch prepare returned.
113
+ * Once committed, the owner is durably retiring: cancellation is no longer
114
+ * safe and this function must never call cancel past this point.
115
+ * 4. Close the client (every path below runs with the client closed).
116
+ * 5. Wait, bounded by the request deadline, for `observeProcessIncarnation`
117
+ * to positively confirm the exact old pid is OS-absent -- never inferred
118
+ * from a changed/missing incarnation read, and never from discovery
119
+ * disappearing (a crashed writer can leave a stale discovery file behind
120
+ * just as easily as a clean exit can remove it).
121
+ * 6. Launch (or adopt an already-published) exactly one successor for this
122
+ * requestId, holding the shared startup exclusion only around the launch
123
+ * decision itself -- never across the successor's own publication wait,
124
+ * which would deadlock this process against its own spawned child.
125
+ *
126
+ * The successor clears the durable committed intent itself, from inside its
127
+ * own `Broker#start()`, only after its own publication has independently
128
+ * proven it owns the root -- never here, and never before that proof exists.
129
+ */
130
+ export async function restartBrokerForDoctor(options: DoctorBrokerRestartOptions): Promise<DoctorBrokerRestartOutcome> {
131
+ const old = await readBrokerDiscovery(options.agentDir);
132
+ if (!old) return { kind: "owner_unavailable", reason: "no_discovery" };
133
+ const oldOwner = identity(old);
134
+ const requestId = options.requestId ?? randomUUID();
135
+ const deadlineMs = options.deadlineMs ?? 30_000;
136
+ const deadlineAt = Date.now() + deadlineMs;
137
+
138
+ let client: SdkClient;
139
+ try {
140
+ client = await SdkClient.connect(old.url, old.token, { timeoutMs: deadlineMs });
141
+ } catch {
142
+ return { kind: "owner_unavailable", reason: "owner_not_confirmed_live" };
143
+ }
144
+
145
+ let prepared: BrokerRestartPrepareResult | undefined;
146
+ try {
147
+ const prepareResult = await prepareDoctorBrokerRestart(client, old, { ...options, requestId });
148
+ if (!prepareResult.ok)
149
+ return { kind: "prepare_refused", code: prepareResult.error.code, message: prepareResult.error.message };
150
+ prepared = prepareResult.result as BrokerRestartPrepareResult;
151
+
152
+ const commitResult = await commitDoctorBrokerRestart(client, old, prepared, requestId);
153
+ if (!commitResult.ok) {
154
+ // A commit refusal leaves the owner's own reservation in place; the
155
+ // owner's lease-expiry timer (or an explicit cancel by the same
156
+ // requestId) is what releases it, never a resurrect-old-owner retry
157
+ // from here.
158
+ try {
159
+ await cancelDoctorBrokerRestart(client, requestId);
160
+ } catch {
161
+ // Best-effort: an unreachable owner past a refused commit still
162
+ // self-expires its own lease.
163
+ }
164
+ return { kind: "commit_refused", code: commitResult.error.code, message: commitResult.error.message };
165
+ }
166
+ } catch (caught) {
167
+ throw caught instanceof SdkClientError
168
+ ? caught
169
+ : new Error(
170
+ `SDK broker restart prepare/commit failed: ${caught instanceof Error ? caught.message : String(caught)}`,
171
+ {
172
+ cause: caught,
173
+ },
174
+ );
175
+ } finally {
176
+ await client.close().catch(() => undefined);
177
+ }
178
+
179
+ // Committed: the owner is durably retiring. Wait for its exact OS
180
+ // incarnation to be positively confirmed absent before authorizing any
181
+ // successor -- never inferred from discovery disappearing or from a
182
+ // PID-reuse-vulnerable incarnation mismatch alone.
183
+ while (Date.now() < deadlineAt) {
184
+ if (oldOwnerConfirmedExited(oldOwner.pid)) break;
185
+ await Bun.sleep(OLD_OWNER_EXIT_POLL_MS);
186
+ }
187
+ if (!oldOwnerConfirmedExited(oldOwner.pid)) return { kind: "old_owner_exit_timeout", requestId, oldOwner };
188
+
189
+ const successor = await launchAuthorizedBrokerSuccessor({
190
+ agentDir: options.agentDir,
191
+ requestId,
192
+ deadlineAt: prepared!.expiresAt,
193
+ packageGeneration: old.packageGeneration,
194
+ });
195
+ if (successor.kind === "refused")
196
+ return { kind: "successor_refused", requestId, oldOwner, reason: successor.reason, detail: successor.detail };
197
+ return {
198
+ kind: "restarted",
199
+ requestId,
200
+ oldOwner,
201
+ successor: successor.discovery,
202
+ adopted: successor.kind === "adopted",
203
+ };
204
+ }
205
+
206
+ export type { BrokerRestartResult };
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import * as fsSync from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
 
@@ -20,6 +21,41 @@ export type IndexedEndpointAuthority = {
20
21
  endpointFileId?: string;
21
22
  };
22
23
 
24
+ /** Derive the broker-compatible opaque authority for one indexed endpoint generation. */
25
+ export function endpointIncarnation(
26
+ record: { endpointGeneration: number; endpointMtimeMs?: number; pid: number; endpointFileId?: string },
27
+ sessionId: string,
28
+ ): string | undefined {
29
+ if (
30
+ !Number.isSafeInteger(record.endpointGeneration) ||
31
+ record.endpointGeneration <= 0 ||
32
+ !Number.isSafeInteger(record.pid) ||
33
+ record.pid <= 0 ||
34
+ typeof record.endpointMtimeMs !== "number" ||
35
+ !Number.isFinite(record.endpointMtimeMs) ||
36
+ record.endpointMtimeMs <= 0
37
+ )
38
+ return undefined;
39
+ return createHash("sha256")
40
+ .update(
41
+ JSON.stringify({
42
+ endpointGeneration: record.endpointGeneration,
43
+ // Filesystem mtime is only millisecond-precise, and the two stat
44
+ // spellings used across this path (bigint mtimeNs/1e6 vs libuv
45
+ // double mtimeMs) can disagree by 1 float ulp (~0.00024ms) for the
46
+ // same file (#5376). Quantize so equivalent reads hash identically.
47
+ // The immutable endpoint file identity (dev:ino) stays exact, so a
48
+ // genuine same-millisecond successor replacement still changes the
49
+ // digest even when pid/generation are reused.
50
+ endpointMtimeMs: Math.round(record.endpointMtimeMs),
51
+ ...(record.endpointFileId === undefined ? {} : { endpointFileId: record.endpointFileId }),
52
+ pid: record.pid,
53
+ sessionId,
54
+ }),
55
+ )
56
+ .digest("hex");
57
+ }
58
+
23
59
  function readFlags(): number {
24
60
  return fsSync.constants.O_RDONLY | (fsSync.constants.O_NOFOLLOW ?? 0) | (fsSync.constants.O_NONBLOCK ?? 0);
25
61
  }
@@ -7,8 +7,17 @@ import packageJson from "../../../package.json" with { type: "json" };
7
7
  import { acquireFileLock, type FileLockOptions, withFileLock } from "../../config/file-lock";
8
8
  import { loadInstallationHostId, loadLegacyInstallationHostId } from "../../config/machine-identity";
9
9
  import { SdkClient } from "../client/client";
10
- import { type BrokerDiscovery, brokerProcessIncarnation, readBrokerDiscovery } from "./discovery";
11
- import { resolveSdkInternalSpawnCommand, type SdkInternalSpawnCommand } from "./runtime";
10
+ import {
11
+ type BrokerDiscovery,
12
+ brokerProcessIncarnation,
13
+ readBrokerDiscovery,
14
+ readBrokerRestartIntent,
15
+ } from "./discovery";
16
+ import {
17
+ isSdkInternalRuntimeImagePresent,
18
+ resolveSdkInternalSpawnCommand,
19
+ type SdkInternalSpawnCommand,
20
+ } from "./runtime";
12
21
  import { BrokerStartupError, clearBrokerStartupFailureMarker, readBrokerStartupFailureMarker } from "./startup-failure";
13
22
 
14
23
  function resolveExpectedBrokerGeneration(): string {
@@ -20,8 +29,27 @@ export function isBrokerGenerationCompatible(discovery: BrokerDiscovery | null):
20
29
  if (!discovery) return false;
21
30
  return discovery.packageGeneration === resolveExpectedBrokerGeneration();
22
31
  }
32
+
33
+ /**
34
+ * A live incumbent stops being reusable once the runtime image it launches
35
+ * internal processes with is provably gone. A broker outlives its own executable
36
+ * (a package manager replacing the interpreter it was started from, an install
37
+ * directory swapped underneath it): it keeps heartbeating and answering
38
+ * requests, but every `session.create` it accepts is refused at spawn time.
39
+ * Retiring it here repairs the endpoint before a caller reaches session
40
+ * creation. Absent runtime evidence -- a record predating the field, or a
41
+ * publisher that could not classify its own runtime at all -- and evidence this
42
+ * caller merely cannot inspect both keep the incumbent reusable.
43
+ */
44
+ export async function isBrokerReusable(discovery: BrokerDiscovery | null): Promise<boolean> {
45
+ if (!isBrokerGenerationCompatible(discovery)) return false;
46
+ const runtime = discovery?.runtime;
47
+ return runtime === undefined || (await isSdkInternalRuntimeImagePresent(runtime));
48
+ }
23
49
  export interface EnsureBrokerSettings {
24
50
  agentDir: string;
51
+ /** Only an authorized doctor successor may consume a restart-intent publication. */
52
+ restartRequestId?: string;
25
53
  heartbeatTtlMs?: number;
26
54
  /**
27
55
  * Environment for the spawned detached broker. Defaults to `process.env`; tests
@@ -387,7 +415,7 @@ export async function closeBrokerClientBeforeDeadline(
387
415
  await Promise.race([close, Bun.sleep(remainingMs)]);
388
416
  }
389
417
 
390
- async function retireStaleBrokerGeneration(
418
+ async function retireUnusableBroker(
391
419
  stale: BrokerDiscovery,
392
420
  settings: EnsureBrokerSettings,
393
421
  outerDeadline = Number.POSITIVE_INFINITY,
@@ -429,10 +457,10 @@ export async function reconcileBrokerGenerationForStartup(
429
457
  ): Promise<BrokerDiscovery | undefined> {
430
458
  const current = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
431
459
  if (!current) return undefined;
432
- if (isBrokerGenerationCompatible(current)) return current;
433
- await retireStaleBrokerGeneration(current, settings, deadline);
460
+ if (await isBrokerReusable(current)) return current;
461
+ await retireUnusableBroker(current, settings, deadline);
434
462
  const replacement = await readBrokerDiscoveryBeforeDeadline(settings.agentDir, settings.heartbeatTtlMs, deadline);
435
- return replacement && isBrokerGenerationCompatible(replacement) ? replacement : undefined;
463
+ return replacement && (await isBrokerReusable(replacement)) ? replacement : undefined;
436
464
  }
437
465
  function createFixtureLeaseFromChild(child: ChildProcess, terminate: () => Promise<void>): ExactFixtureBrokerLease {
438
466
  let termination: Promise<void> | undefined;
@@ -485,23 +513,27 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
485
513
  settings.heartbeatTtlMs,
486
514
  initialDiscoveryDeadline,
487
515
  );
516
+ const restartIntent = await readBrokerRestartIntent(settings.agentDir);
517
+ if (restartIntent && restartIntent.expiresAt > Date.now() && restartIntent.requestId !== settings.restartRequestId)
518
+ throw new Error("broker_restart_in_progress");
488
519
  if (initiator === "fixture-lease" && (priorOwner || existing)) throw fixtureLeaseUnavailable();
489
520
  if (priorOwner) {
490
521
  // A retained cleanup failure fences every discovery record. Only a ready
491
522
  // record bound to this exact child incarnation may be reused.
492
- if (priorOwner.canReuse(existing)) return { kind: "prior-local-owner", discovery: existing!, owner: priorOwner };
523
+ if (priorOwner.canReuse(existing) && (await isBrokerReusable(existing)))
524
+ return { kind: "prior-local-owner", discovery: existing!, owner: priorOwner };
493
525
  await priorOwner.stop();
494
526
  const discoveredAfterCleanup = await readBrokerDiscoveryBeforeDeadline(
495
527
  settings.agentDir,
496
528
  settings.heartbeatTtlMs,
497
529
  initialDiscoveryDeadline,
498
530
  );
499
- if (discoveredAfterCleanup && isBrokerGenerationCompatible(discoveredAfterCleanup))
531
+ if (discoveredAfterCleanup && (await isBrokerReusable(discoveredAfterCleanup)))
500
532
  return { kind: "external-discovery", discovery: discoveredAfterCleanup };
501
- // Stale-generation retirement is serialized under the spawn lock below.
533
+ // Unusable-incumbent retirement is serialized under the spawn lock below.
502
534
  } else if (existing) {
503
- if (isBrokerGenerationCompatible(existing)) return { kind: "external-discovery", discovery: existing };
504
- // Stale-generation retirement is serialized under the spawn lock below.
535
+ if (await isBrokerReusable(existing)) return { kind: "external-discovery", discovery: existing };
536
+ // Unusable-incumbent retirement is serialized under the spawn lock below.
505
537
  }
506
538
 
507
539
  // Only one process may spawn for this agent dir at a time; everyone else
@@ -512,6 +544,9 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
512
544
  try {
513
545
  const deadline =
514
546
  Date.now() + (initiator === "fixture-lease" ? FIXTURE_DISCOVERY_TIMEOUT_MS : DISCOVERY_TIMEOUT_MS);
547
+ const lockedIntent = await readBrokerRestartIntent(settings.agentDir);
548
+ if (lockedIntent && lockedIntent.expiresAt > Date.now() && lockedIntent.requestId !== settings.restartRequestId)
549
+ throw new Error("broker_restart_in_progress");
515
550
  // The lock winner may still find a discovery published by an earlier winner
516
551
  // that finished between our first read and the lock acquisition.
517
552
  const discoveredUnderLock = await reconcileBrokerGenerationForStartup(settings, deadline);
@@ -546,7 +581,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
546
581
  deadline,
547
582
  );
548
583
  if (discovered) {
549
- if (!isBrokerGenerationCompatible(discovered)) {
584
+ if (!(await isBrokerReusable(discovered))) {
550
585
  await sleep(50);
551
586
  continue;
552
587
  }
@@ -577,7 +612,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
577
612
  settings.heartbeatTtlMs,
578
613
  deadline,
579
614
  );
580
- if (winner && isBrokerGenerationCompatible(winner)) {
615
+ if (winner && (await isBrokerReusable(winner))) {
581
616
  await owner.stop();
582
617
  return { kind: "external-discovery", discovery: winner };
583
618
  }
@@ -622,7 +657,7 @@ async function ensureBrokerOnce(settings: EnsureBrokerSettings, initiator: Ensur
622
657
  settings.heartbeatTtlMs,
623
658
  recoveryDeadline,
624
659
  );
625
- if (incumbent && isBrokerGenerationCompatible(incumbent)) {
660
+ if (incumbent && (await isBrokerReusable(incumbent))) {
626
661
  await owner.stop();
627
662
  return { kind: "external-discovery", discovery: incumbent };
628
663
  }