@gajae-code/coding-agent 0.18.7 → 0.18.9

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 (344) hide show
  1. package/CHANGELOG.md +377 -1
  2. package/dist/types/async/endpoint-id.d.ts +16 -0
  3. package/dist/types/async/job-manager.d.ts +1 -1
  4. package/dist/types/async/support.d.ts +0 -14
  5. package/dist/types/capability/mcp.d.ts +2 -0
  6. package/dist/types/capability/skill.d.ts +2 -0
  7. package/dist/types/capability/types.d.ts +2 -0
  8. package/dist/types/cli/public-command-errors.d.ts +3 -1
  9. package/dist/types/cli/setup-cli.d.ts +1 -1
  10. package/dist/types/commands/deep-interview.d.ts +3 -0
  11. package/dist/types/commands/mcp-serve.d.ts +4 -0
  12. package/dist/types/config/autorouting-tier-map.d.ts +348 -9
  13. package/dist/types/config/file-lock.d.ts +1 -0
  14. package/dist/types/config/model-bindings-applier.d.ts +2 -0
  15. package/dist/types/config/model-profile-activation.d.ts +11 -3
  16. package/dist/types/config/model-registry.d.ts +4 -9
  17. package/dist/types/config/skill-settings-defaults.d.ts +2 -0
  18. package/dist/types/cursor-delete-path.d.ts +1 -0
  19. package/dist/types/cursor-mcp-name.d.ts +8 -0
  20. package/dist/types/customization/inventory.d.ts +2 -0
  21. package/dist/types/debug/report-bundle-parent.d.ts +2 -0
  22. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +1 -0
  23. package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.d.ts +31 -0
  24. package/dist/types/defaults/gjc-skills.generated.d.ts +5 -6
  25. package/dist/types/discovery/helpers.d.ts +6 -1
  26. package/dist/types/discovery/mcp-json.d.ts +1 -0
  27. package/dist/types/eval/py/kernel.d.ts +18 -0
  28. package/dist/types/export/html/css-color.d.ts +2 -0
  29. package/dist/types/export/html/html-export-destination.d.ts +2 -0
  30. package/dist/types/export/html/template.generated.d.ts +1 -1
  31. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +5 -0
  32. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  33. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +14 -1
  34. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +2 -0
  35. package/dist/types/extensibility/plugins/plugin-link-path.d.ts +2 -0
  36. package/dist/types/extensibility/runtime-skill-discovery.d.ts +3 -1
  37. package/dist/types/extensibility/skill-management.d.ts +4 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/deep-interview-crystallize.d.ts +74 -0
  40. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +11 -0
  41. package/dist/types/gjc-runtime/gc-runtime.d.ts +5 -0
  42. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -3
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +0 -5
  44. package/dist/types/gjc-runtime/sidecar-bootstrap-env.d.ts +6 -0
  45. package/dist/types/gjc-runtime/state-argv.d.ts +1 -1
  46. package/dist/types/gjc-runtime/state-migrations.d.ts +8 -0
  47. package/dist/types/gjc-runtime/state-runtime.d.ts +95 -0
  48. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  49. package/dist/types/gjc-runtime/tmux-common.d.ts +8 -5
  50. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -0
  51. package/dist/types/internal-urls/skill-path.d.ts +1 -0
  52. package/dist/types/lsp/config.d.ts +8 -5
  53. package/dist/types/lsp/workspace-path.d.ts +1 -0
  54. package/dist/types/modes/acp/acp-agent.d.ts +7 -0
  55. package/dist/types/modes/components/custom-editor.d.ts +6 -0
  56. package/dist/types/modes/components/customization/customization-dashboard.d.ts +1 -0
  57. package/dist/types/modes/components/extensions/state-manager.d.ts +4 -3
  58. package/dist/types/modes/controllers/input-controller.d.ts +9 -0
  59. package/dist/types/modes/interactive-mode.d.ts +1 -0
  60. package/dist/types/modes/utils/interrupt-ownership.d.ts +3 -0
  61. package/dist/types/modes/utils/keybinding-matchers.d.ts +1 -0
  62. package/dist/types/modes/utils/macos-option-forwarding.d.ts +17 -0
  63. package/dist/types/progress/collect-project-progress.d.ts +38 -0
  64. package/dist/types/progress/progress-contract.d.ts +154 -0
  65. package/dist/types/progress/progress-snapshot.d.ts +11 -0
  66. package/dist/types/progress/project-progress.d.ts +141 -0
  67. package/dist/types/progress/render-progress.d.ts +28 -0
  68. package/dist/types/runtime-mcp/manager.d.ts +2 -1
  69. package/dist/types/runtime-mcp/mcp-redirect.d.ts +9 -0
  70. package/dist/types/runtime-mcp/oauth-public-url.d.ts +3 -0
  71. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +1 -0
  72. package/dist/types/runtime-mcp/project-host-values.d.ts +27 -0
  73. package/dist/types/runtime-mcp/smithery-registry.d.ts +24 -0
  74. package/dist/types/runtime-mcp/types.d.ts +6 -0
  75. package/dist/types/sdk/acp/adapter.d.ts +1 -1
  76. package/dist/types/sdk/broker/broker-exit.d.ts +6 -2
  77. package/dist/types/sdk/broker/broker.d.ts +24 -2
  78. package/dist/types/sdk/broker/doctor-restart.d.ts +5 -0
  79. package/dist/types/sdk/broker/ensure.d.ts +14 -1
  80. package/dist/types/sdk/broker/session-index.d.ts +12 -6
  81. package/dist/types/sdk/bus/index.d.ts +12 -1
  82. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -1
  83. package/dist/types/sdk/client/client.d.ts +2 -0
  84. package/dist/types/sdk/host/query/handlers.d.ts +3 -0
  85. package/dist/types/sdk/index.d.ts +1 -0
  86. package/dist/types/sdk/router/session-router.d.ts +2 -0
  87. package/dist/types/sdk/session-list.d.ts +1 -1
  88. package/dist/types/sdk/session.d.ts +8 -0
  89. package/dist/types/session/agent-session.d.ts +38 -9
  90. package/dist/types/session/artifacts.d.ts +2 -2
  91. package/dist/types/session/internal/managed-gc-retirement-codec.d.ts +32 -0
  92. package/dist/types/session/internal/managed-session-scope.d.ts +79 -2
  93. package/dist/types/session/internal/managed-session-storage.d.ts +26 -1
  94. package/dist/types/session/internal/managed-task-owner-cleanup.d.ts +62 -0
  95. package/dist/types/session/internal/task-artifact-owner-access.d.ts +2 -0
  96. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +9 -1
  97. package/dist/types/session/session-manager.d.ts +31 -5
  98. package/dist/types/session/session-retirement.d.ts +31 -7
  99. package/dist/types/session/session-storage.d.ts +69 -2
  100. package/dist/types/session/task-artifact-owner-codec.d.ts +2 -0
  101. package/dist/types/setup/credential-auto-import.d.ts +1 -1
  102. package/dist/types/setup/credential-import.d.ts +6 -4
  103. package/dist/types/skill-state/active-state.d.ts +38 -0
  104. package/dist/types/skill-state/workflow-mutation-guard.d.ts +3 -0
  105. package/dist/types/slash-commands/helpers/progress-report.d.ts +8 -0
  106. package/dist/types/task/executor.d.ts +3 -0
  107. package/dist/types/task/isolation-dir.d.ts +26 -0
  108. package/dist/types/tools/ast-edit-write-path.d.ts +13 -0
  109. package/dist/types/tools/bash-skill-urls.d.ts +1 -1
  110. package/dist/types/tools/browser/cdp-url.d.ts +2 -0
  111. package/dist/types/tools/github-side-effect.d.ts +22 -0
  112. package/dist/types/tools/index.d.ts +3 -0
  113. package/dist/types/tools/monitor-mutation.d.ts +9 -0
  114. package/dist/types/tools/python.d.ts +9 -1
  115. package/dist/types/tools/recipe/recipe-guard.d.ts +7 -0
  116. package/dist/types/tools/restricted-cwd.d.ts +1 -0
  117. package/dist/types/tools/todo-write.d.ts +6 -0
  118. package/dist/types/utils/tools-checksum.d.ts +7 -0
  119. package/package.json +7 -7
  120. package/scripts/generate-sdk-operation-inventory.ts +21 -0
  121. package/scripts/verify-managed-empty-stop.ts +28 -0
  122. package/src/async/endpoint-id.ts +26 -0
  123. package/src/async/job-manager.ts +247 -132
  124. package/src/async/support.ts +0 -25
  125. package/src/capability/index.ts +15 -0
  126. package/src/capability/mcp.ts +2 -0
  127. package/src/capability/skill.ts +2 -0
  128. package/src/capability/types.ts +2 -0
  129. package/src/cli/auth-broker-cli.ts +1 -1
  130. package/src/cli/customize-doctor.ts +15 -2
  131. package/src/cli/doctor/repairs.ts +4 -1
  132. package/src/cli/doctor-supervisor.ts +3 -0
  133. package/src/cli/public-command-errors.ts +21 -1
  134. package/src/cli/public-command-registry.ts +28 -6
  135. package/src/cli/setup-cli.ts +4 -4
  136. package/src/cli/skills-cli.ts +5 -0
  137. package/src/commands/deep-interview.ts +47 -8
  138. package/src/commands/mcp-serve.ts +7 -0
  139. package/src/commands/sdk.ts +455 -397
  140. package/src/config/autorouting-tier-map.ts +117 -7
  141. package/src/config/file-lock.ts +316 -79
  142. package/src/config/mcp-schema.json +4 -0
  143. package/src/config/model-bindings-applier.ts +20 -0
  144. package/src/config/model-profile-activation.ts +240 -46
  145. package/src/config/model-registry.ts +47 -153
  146. package/src/config/skill-settings-defaults.ts +7 -0
  147. package/src/coordinator-mcp/policy.ts +3 -2
  148. package/src/coordinator-mcp/server.ts +2 -0
  149. package/src/cursor-delete-path.ts +10 -0
  150. package/src/cursor-mcp-name.ts +15 -0
  151. package/src/cursor.ts +13 -2
  152. package/src/customization/inventory.ts +4 -13
  153. package/src/dap/config.ts +2 -2
  154. package/src/debug/report-bundle-parent.ts +22 -0
  155. package/src/debug/report-bundle.ts +3 -0
  156. package/src/debug/system-info.ts +13 -1
  157. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +10 -6
  158. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +67 -4
  159. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/version-manager.ts +80 -0
  160. package/src/defaults/gjc/skills/deep-interview/SKILL.md +105 -10
  161. package/src/defaults/gjc/skills/ralplan/SKILL.md +6 -4
  162. package/src/defaults/gjc-defaults.test.ts +18 -1
  163. package/src/defaults/gjc-defaults.ts +3 -0
  164. package/src/defaults/gjc-skills.generated.ts +58 -36
  165. package/src/discovery/agents.ts +6 -1
  166. package/src/discovery/builtin.ts +13 -1
  167. package/src/discovery/helpers.ts +171 -29
  168. package/src/discovery/mcp-json.ts +26 -12
  169. package/src/discovery/ssh.ts +1 -1
  170. package/src/discovery/substitute-plugin-root.ts +3 -1
  171. package/src/edit/file-read-cache.ts +1 -1
  172. package/src/eval/py/executor.ts +796 -154
  173. package/src/eval/py/kernel.ts +79 -13
  174. package/src/eval/py/tool-bridge.ts +4 -0
  175. package/src/export/html/css-color.ts +9 -0
  176. package/src/export/html/html-export-destination.ts +16 -0
  177. package/src/export/html/index.ts +7 -10
  178. package/src/export/html/template.generated.ts +1 -1
  179. package/src/extensibility/extensions/function-hooks-internal.ts +27 -0
  180. package/src/extensibility/extensions/runner.ts +15 -1
  181. package/src/extensibility/extensions/types.ts +7 -0
  182. package/src/extensibility/gjc-plugins/mcp-policy.ts +2 -1
  183. package/src/extensibility/gjc-plugins/runtime-adapters.ts +85 -8
  184. package/src/extensibility/plugins/manager.ts +3 -2
  185. package/src/extensibility/plugins/marketplace/cache.ts +15 -0
  186. package/src/extensibility/plugins/marketplace/manager.ts +11 -3
  187. package/src/extensibility/plugins/plugin-link-path.ts +12 -0
  188. package/src/extensibility/runtime-skill-discovery.ts +20 -2
  189. package/src/extensibility/skill-management.ts +9 -2
  190. package/src/extensibility/skills.ts +6 -1
  191. package/src/gjc-runtime/deep-interview-crystallize.ts +1814 -0
  192. package/src/gjc-runtime/deep-interview-recorder.ts +20 -2
  193. package/src/gjc-runtime/deep-interview-runtime.ts +1415 -13
  194. package/src/gjc-runtime/deep-interview-stage.ts +98 -21
  195. package/src/gjc-runtime/gc-runtime.ts +206 -9
  196. package/src/gjc-runtime/launch-worktree.ts +70 -9
  197. package/src/gjc-runtime/psmux-detect.ts +28 -4
  198. package/src/gjc-runtime/ralplan-runtime.ts +447 -52
  199. package/src/gjc-runtime/session-state-sidecar.ts +3 -2
  200. package/src/gjc-runtime/sidecar-bootstrap-env.ts +19 -0
  201. package/src/gjc-runtime/state-argv.ts +2 -0
  202. package/src/gjc-runtime/state-migrations.ts +91 -29
  203. package/src/gjc-runtime/state-runtime.ts +3716 -135
  204. package/src/gjc-runtime/state-writer.ts +105 -16
  205. package/src/gjc-runtime/tmux-common.ts +8 -5
  206. package/src/gjc-runtime/ultragoal-runtime.ts +16 -4
  207. package/src/gjc-runtime/workflow-manifest.generated.json +70 -42
  208. package/src/gjc-runtime/workflow-manifest.ts +54 -21
  209. package/src/harness-control-plane/operate.ts +12 -4
  210. package/src/harness-control-plane/owner.ts +5 -1
  211. package/src/hashline/parser.ts +48 -9
  212. package/src/hashline/recovery.ts +13 -4
  213. package/src/internal-urls/docs-index.generated.ts +20 -19
  214. package/src/internal-urls/skill-path.ts +19 -0
  215. package/src/internal-urls/skill-protocol.ts +2 -0
  216. package/src/lsp/config.ts +12 -63
  217. package/src/lsp/edits.ts +6 -0
  218. package/src/lsp/workspace-path.ts +51 -0
  219. package/src/main.ts +195 -31
  220. package/src/memories/index.ts +88 -7
  221. package/src/modes/acp/acp-agent.ts +88 -4
  222. package/src/modes/components/custom-editor.ts +14 -0
  223. package/src/modes/components/customization/customization-dashboard.ts +12 -0
  224. package/src/modes/components/extensions/extension-dashboard.ts +2 -2
  225. package/src/modes/components/extensions/state-manager.ts +15 -5
  226. package/src/modes/components/hook-editor.ts +1 -1
  227. package/src/modes/components/hook-selector.ts +1 -1
  228. package/src/modes/components/model-selector.ts +1 -4
  229. package/src/modes/components/provider-onboarding-selector.ts +1 -1
  230. package/src/modes/components/status-line/segments.ts +4 -0
  231. package/src/modes/controllers/event-controller.ts +11 -4
  232. package/src/modes/controllers/input-controller.ts +76 -31
  233. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -16
  234. package/src/modes/controllers/selector-controller.ts +24 -15
  235. package/src/modes/controllers/todo-command-controller.ts +1 -1
  236. package/src/modes/interactive-mode.ts +5 -1
  237. package/src/modes/runtime-init.ts +4 -0
  238. package/src/modes/utils/abort-message.ts +3 -1
  239. package/src/modes/utils/interrupt-ownership.ts +12 -0
  240. package/src/modes/utils/keybinding-matchers.ts +4 -0
  241. package/src/modes/utils/macos-option-forwarding.ts +80 -0
  242. package/src/progress/collect-project-progress.ts +153 -0
  243. package/src/progress/progress-contract.ts +172 -0
  244. package/src/progress/progress-snapshot.ts +140 -0
  245. package/src/progress/project-progress.ts +446 -0
  246. package/src/progress/render-progress.ts +209 -0
  247. package/src/prompts/system/master-mode.md +1 -0
  248. package/src/prompts/tools/skill-discovery.md +1 -1
  249. package/src/runtime-mcp/client.ts +4 -2
  250. package/src/runtime-mcp/config-writer.ts +10 -6
  251. package/src/runtime-mcp/config.ts +1 -0
  252. package/src/runtime-mcp/json-rpc.ts +2 -1
  253. package/src/runtime-mcp/manager.ts +29 -11
  254. package/src/runtime-mcp/mcp-redirect.ts +73 -0
  255. package/src/runtime-mcp/oauth-flow.ts +14 -2
  256. package/src/runtime-mcp/oauth-public-url.ts +14 -0
  257. package/src/runtime-mcp/plugin-network-boundary.ts +4 -0
  258. package/src/runtime-mcp/pool.ts +6 -0
  259. package/src/runtime-mcp/project-host-values.ts +46 -0
  260. package/src/runtime-mcp/smithery-registry.ts +22 -3
  261. package/src/runtime-mcp/transports/http.ts +4 -3
  262. package/src/runtime-mcp/types.ts +6 -0
  263. package/src/sdk/acp/adapter.ts +95 -8
  264. package/src/sdk/broker/broker-exit.ts +52 -23
  265. package/src/sdk/broker/broker.ts +81 -6
  266. package/src/sdk/broker/doctor-restart.ts +4 -0
  267. package/src/sdk/broker/ensure.ts +31 -2
  268. package/src/sdk/broker/lifecycle.ts +711 -66
  269. package/src/sdk/broker/session-index.ts +191 -81
  270. package/src/sdk/bus/chat-daemon-cli.ts +38 -1
  271. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  272. package/src/sdk/bus/index.ts +238 -43
  273. package/src/sdk/bus/telegram-daemon-contract.ts +6 -1
  274. package/src/sdk/cli/session-cli.ts +17 -3
  275. package/src/sdk/client/client.ts +28 -8
  276. package/src/sdk/host/query/handlers.ts +9 -1
  277. package/src/sdk/host/session-runtime.test.ts +2705 -303
  278. package/src/sdk/host/session-runtime.ts +490 -143
  279. package/src/sdk/host/surface-policy.ts +1 -0
  280. package/src/sdk/index.ts +8 -0
  281. package/src/sdk/lifecycle-session.ts +3 -1
  282. package/src/sdk/prompt-deadline-manager.ts +15 -0
  283. package/src/sdk/protocol/operation-inventory.generated.json +146 -0
  284. package/src/sdk/protocol/operation-registry.ts +9 -2
  285. package/src/sdk/router/session-router.ts +82 -18
  286. package/src/sdk/session-list.ts +1 -0
  287. package/src/sdk/session.ts +69 -45
  288. package/src/session/agent-session.ts +679 -238
  289. package/src/session/artifacts.ts +176 -71
  290. package/src/session/internal/managed-gc-retirement-codec.ts +319 -0
  291. package/src/session/internal/managed-session-scope.ts +3779 -462
  292. package/src/session/internal/managed-session-storage.ts +501 -78
  293. package/src/session/internal/managed-task-owner-cleanup.ts +473 -0
  294. package/src/session/internal/task-artifact-owner-access.ts +19 -3
  295. package/src/session/internal/task-artifact-owner-transcript.ts +547 -1
  296. package/src/session/session-manager.ts +951 -133
  297. package/src/session/session-retirement.ts +573 -14
  298. package/src/session/session-storage.ts +447 -15
  299. package/src/session/task-artifact-owner-codec.ts +71 -8
  300. package/src/session/task-artifact-owner-retirement.ts +21 -19
  301. package/src/setup/credential-auto-import.ts +12 -3
  302. package/src/setup/credential-import.ts +176 -5
  303. package/src/setup/paseo/announce.ts +6 -1
  304. package/src/skill-state/active-state.ts +145 -2
  305. package/src/skill-state/workflow-mutation-guard.ts +102 -10
  306. package/src/slash-commands/builtin-registry.ts +39 -9
  307. package/src/slash-commands/helpers/mcp.ts +8 -4
  308. package/src/slash-commands/helpers/progress-report.ts +23 -0
  309. package/src/stt/setup.ts +3 -5
  310. package/src/task/commands.ts +3 -1
  311. package/src/task/executor.ts +4 -0
  312. package/src/task/index.ts +6 -0
  313. package/src/task/isolation-dir.ts +143 -0
  314. package/src/task/worktree.ts +16 -7
  315. package/src/tools/ask.ts +205 -6
  316. package/src/tools/ast-edit-write-path.ts +68 -0
  317. package/src/tools/ast-edit.ts +10 -1
  318. package/src/tools/bash-allowed-prefixes.ts +15 -0
  319. package/src/tools/bash-skill-urls.ts +56 -3
  320. package/src/tools/bash.ts +48 -79
  321. package/src/tools/browser/cdp-url.ts +18 -0
  322. package/src/tools/browser.ts +8 -1
  323. package/src/tools/descriptors.ts +5 -1
  324. package/src/tools/eval.ts +56 -36
  325. package/src/tools/gh.ts +10 -0
  326. package/src/tools/github-side-effect.ts +112 -0
  327. package/src/tools/image-gen.ts +1 -1
  328. package/src/tools/index.ts +3 -0
  329. package/src/tools/monitor-mutation.ts +27 -0
  330. package/src/tools/monitor.ts +7 -0
  331. package/src/tools/python.ts +188 -66
  332. package/src/tools/recipe/index.ts +9 -0
  333. package/src/tools/recipe/recipe-guard.ts +19 -0
  334. package/src/tools/restricted-cwd.ts +10 -0
  335. package/src/tools/skill-discovery.ts +5 -0
  336. package/src/tools/skill.ts +28 -1
  337. package/src/tools/todo-write.ts +10 -1
  338. package/src/tools/tool-catalog.generated.ts +1 -1
  339. package/src/tools/write.ts +1 -0
  340. package/src/utils/external-editor.ts +3 -2
  341. package/src/utils/tools-checksum.ts +34 -0
  342. package/src/utils/tools-manager.ts +24 -6
  343. package/src/web/insane/url-guard.ts +2 -1
  344. package/src/web/search/providers/codex.ts +3 -2
@@ -11,7 +11,9 @@ import {
11
11
  checkPythonKernelAvailability,
12
12
  type KernelExecuteOptions,
13
13
  type KernelExecuteResult,
14
+ type KernelShutdownResult,
14
15
  PythonKernel,
16
+ PythonKernelStartError,
15
17
  } from "./kernel";
16
18
  import type { PythonRuntimeOptions } from "./runtime";
17
19
  import { ensurePyToolBridge, registerPyToolBridge } from "./tool-bridge";
@@ -114,14 +116,42 @@ interface PythonSession {
114
116
  kernelInstanceId: string;
115
117
  bridgeCapability?: string;
116
118
  ownerIds: Set<string>;
119
+ durableOwnerIds: Set<string>;
120
+ provisionalOwnerCounts: Map<string, number>;
117
121
  hasFallbackOwner: boolean;
118
122
  queue: Promise<void>;
123
+ cleanupPromise?: Promise<KernelShutdownResult>;
124
+ cleanupStart?: () => void;
119
125
  }
120
126
 
121
127
  interface InitializingPythonSession {
122
128
  sessionId: string;
123
129
  promise: Promise<PythonSession>;
130
+ startupController: AbortController;
131
+ requests: Set<ActivePythonRequest>;
132
+ ownerIds: Set<string>;
133
+ retirementOwnerIds: Set<string>;
134
+ hasFallbackOwner: boolean;
124
135
  cancelled?: PythonExecutionCancelledError;
136
+ kernel?: PythonKernel;
137
+ cleanupAttempt?: Promise<KernelShutdownResult>;
138
+ cleanupError?: unknown;
139
+ cleanupFailed?: boolean;
140
+ }
141
+
142
+ interface ActivePythonRequest {
143
+ readonly ownerId?: string;
144
+ readonly sessionId?: string;
145
+ readonly fallbackOwner: boolean;
146
+ readonly controller: AbortController;
147
+ signal?: AbortSignal;
148
+ initializer?: InitializingPythonSession;
149
+ initializerAcquired?: boolean;
150
+ provisionalSession?: PythonSession;
151
+ readonly completion: Promise<void>;
152
+ readonly kernels: Set<PythonKernel>;
153
+ readonly cleanupAttempts: Map<PythonKernel, Promise<KernelShutdownResult>>;
154
+ readonly finish: () => void;
125
155
  }
126
156
 
127
157
  let pythonResourceCleanupRegistered = false;
@@ -134,6 +164,11 @@ function ensurePythonResourceCleanup(): void {
134
164
  const sessions = new Map<string, PythonSession | InitializingPythonSession>();
135
165
  const retiringKernels = new Set<PythonKernel>();
136
166
  const settingsScopes = new WeakMap<Settings, string>();
167
+ const activeRequests = new Set<ActivePythonRequest>();
168
+ const retiringSessions = new Set<PythonSession>();
169
+ const initializingSessions = new Set<InitializingPythonSession>();
170
+ const retiringKernelShutdowns = new Map<PythonKernel, Promise<KernelShutdownResult>>();
171
+ const retiringKernelOwners = new Map<PythonKernel, Set<string>>();
137
172
 
138
173
  /**
139
174
  * Fingerprint the settings values that actually shape a spawned kernel process.
@@ -169,11 +204,118 @@ function scopedSessionId(sessionId: string, activeSettings: Settings | undefined
169
204
  return `${sessionId}:settings-${settingsScope(activeSettings)}`;
170
205
  }
171
206
 
172
- async function shutdownOrRetainKernel(kernel: PythonKernel): Promise<void> {
173
- const result = await kernel.shutdown().catch(() => undefined);
174
- if (result?.confirmed) return;
207
+ function retainKernelForOwners(kernel: PythonKernel, ownerIds: Set<string>): void {
175
208
  retiringKernels.add(kernel);
176
- logger.warn("Python kernel shutdown not confirmed", { kernelId: kernel.id });
209
+ const owners = retiringKernelOwners.get(kernel) ?? new Set<string>();
210
+ for (const ownerId of ownerIds) owners.add(ownerId);
211
+ retiringKernelOwners.set(kernel, owners);
212
+ }
213
+
214
+ function retainKernelForOwner(kernel: PythonKernel | undefined, ownerId: string): void {
215
+ if (!kernel) return;
216
+ retiringKernels.add(kernel);
217
+ const owners = retiringKernelOwners.get(kernel) ?? new Set<string>();
218
+ owners.add(ownerId);
219
+ retiringKernelOwners.set(kernel, owners);
220
+ }
221
+
222
+ function removeKernelOwner(kernel: PythonKernel | undefined, ownerId: string): void {
223
+ if (kernel) retiringKernelOwners.get(kernel)?.delete(ownerId);
224
+ }
225
+
226
+ async function shutdownOrRetainKernel(kernel: PythonKernel, ownerIds: Set<string>, timeoutMs?: number): Promise<void> {
227
+ retainKernelForOwners(kernel, ownerIds);
228
+ const [shutdown, start] = prepareRetiringKernelShutdown(kernel, timeoutMs);
229
+ start();
230
+ try {
231
+ await shutdown;
232
+ } catch (error) {
233
+ logger.warn("Python kernel shutdown not confirmed", { kernelId: kernel.id, reason: error });
234
+ throw error;
235
+ }
236
+ }
237
+
238
+ function prepareSessionShutdown(session: PythonSession): Promise<KernelShutdownResult> {
239
+ if (session.cleanupPromise) return session.cleanupPromise;
240
+ const { promise: cleanup, resolve, reject } = Promise.withResolvers<KernelShutdownResult>();
241
+ retiringSessions.add(session);
242
+ session.cleanupPromise = cleanup;
243
+ retainKernelForOwners(session.kernel, session.ownerIds);
244
+ const [kernelCleanup, startKernelCleanup] = prepareRetiringKernelShutdown(session.kernel);
245
+ session.cleanupStart = () => {
246
+ startKernelCleanup();
247
+ void kernelCleanup.then(resolve, reject);
248
+ };
249
+ void cleanup.then(
250
+ result => {
251
+ if (result.confirmed) retiringSessions.delete(session);
252
+ if (session.cleanupPromise === cleanup) {
253
+ session.cleanupPromise = undefined;
254
+ session.cleanupStart = undefined;
255
+ }
256
+ },
257
+ () => {
258
+ retiringSessions.add(session);
259
+ if (session.cleanupPromise === cleanup) {
260
+ session.cleanupPromise = undefined;
261
+ session.cleanupStart = undefined;
262
+ }
263
+ },
264
+ );
265
+ return cleanup;
266
+ }
267
+
268
+ function startSessionShutdown(session: PythonSession): void {
269
+ const start = session.cleanupStart;
270
+ if (!start) return;
271
+ session.cleanupStart = undefined;
272
+ start();
273
+ }
274
+
275
+ function callKernelShutdown(kernel: PythonKernel, timeoutMs?: number): Promise<KernelShutdownResult> {
276
+ try {
277
+ return kernel.shutdown(timeoutMs === undefined ? undefined : { timeoutMs });
278
+ } catch (error) {
279
+ return Promise.reject(error);
280
+ }
281
+ }
282
+
283
+ function confirmedKernelShutdown(kernel: PythonKernel, timeoutMs?: number): Promise<KernelShutdownResult> {
284
+ return callKernelShutdown(kernel, timeoutMs).then(result => {
285
+ if (!result.confirmed) throw unconfirmedShutdownError(kernel);
286
+ return result;
287
+ });
288
+ }
289
+
290
+ function prepareRetiringKernelShutdown(
291
+ kernel: PythonKernel,
292
+ timeoutMs?: number,
293
+ ): [Promise<KernelShutdownResult>, () => void] {
294
+ const existing = retiringKernelShutdowns.get(kernel);
295
+ if (existing) return [existing, () => {}];
296
+ const { promise, resolve, reject } = Promise.withResolvers<KernelShutdownResult>();
297
+ retiringKernelShutdowns.set(kernel, promise);
298
+ let started = false;
299
+ void promise.then(
300
+ result => {
301
+ if (result.confirmed) {
302
+ retiringKernels.delete(kernel);
303
+ retiringKernelOwners.delete(kernel);
304
+ }
305
+ if (retiringKernelShutdowns.get(kernel) === promise) retiringKernelShutdowns.delete(kernel);
306
+ },
307
+ () => {
308
+ if (retiringKernelShutdowns.get(kernel) === promise) retiringKernelShutdowns.delete(kernel);
309
+ },
310
+ );
311
+ return [
312
+ promise,
313
+ () => {
314
+ if (started) return;
315
+ started = true;
316
+ void confirmedKernelShutdown(kernel, timeoutMs).then(resolve, reject);
317
+ },
318
+ ];
177
319
  }
178
320
 
179
321
  function isInitializingSession(
@@ -196,12 +338,86 @@ class PythonExecutionCancelledError extends Error {
196
338
  }
197
339
  }
198
340
 
341
+ class PythonKernelCleanupError extends Error {
342
+ constructor(
343
+ kernel: PythonKernel,
344
+ readonly cleanupError: unknown,
345
+ ) {
346
+ super(`Python kernel cleanup failed: ${kernel.id}`, { cause: cleanupError });
347
+ this.name = "PythonKernelCleanupError";
348
+ }
349
+ }
350
+
199
351
  function getExecutionDeadlineMs(options?: Pick<PythonExecutorOptions, "deadlineMs" | "timeoutMs">): number | undefined {
200
352
  if (options?.deadlineMs !== undefined) return options.deadlineMs;
201
353
  if (options?.timeoutMs === undefined) return undefined;
202
354
  return Date.now() + options.timeoutMs;
203
355
  }
204
356
 
357
+ function beginPythonRequest(
358
+ options?: PythonExecutorOptions,
359
+ useSessionFallback = false,
360
+ ): {
361
+ options: PythonExecutorOptions;
362
+ request: ActivePythonRequest;
363
+ cwd?: string;
364
+ } {
365
+ ensurePythonResourceCleanup();
366
+ const capturedOptions = { ...(options ?? {}) };
367
+ const cwd = useSessionFallback ? (capturedOptions.cwd ?? getProjectDir()) : undefined;
368
+ const sessionId =
369
+ useSessionFallback && capturedOptions.kernelMode !== "per-call"
370
+ ? scopedSessionId(
371
+ capturedOptions.sessionId ?? `session:${cwd}`,
372
+ capturedOptions.settings,
373
+ capturedOptions.sessionId !== undefined,
374
+ )
375
+ : undefined;
376
+ const ownerId = capturedOptions.kernelOwnerId ?? sessionId;
377
+ const deadlineMs = getExecutionDeadlineMs(capturedOptions);
378
+ const controller = new AbortController();
379
+ const { promise: completion, resolve } = Promise.withResolvers<void>();
380
+ let deadlineTimer: NodeJS.Timeout | undefined;
381
+ const request: ActivePythonRequest = {
382
+ ownerId,
383
+ sessionId,
384
+ fallbackOwner: capturedOptions.kernelOwnerId === undefined,
385
+ controller,
386
+ completion,
387
+ kernels: new Set(),
388
+ cleanupAttempts: new Map(),
389
+ finish: () => {
390
+ detachInitializerRequest(request, false);
391
+ detachProvisionalSessionRequest(request);
392
+ activeRequests.delete(request);
393
+ if (deadlineTimer) clearTimeout(deadlineTimer);
394
+ resolve();
395
+ },
396
+ };
397
+ activeRequests.add(request);
398
+
399
+ const signals = [controller.signal];
400
+ if (capturedOptions.signal) signals.push(capturedOptions.signal);
401
+ const signal = AbortSignal.any(signals);
402
+ request.signal = signal;
403
+ if (sessionId !== undefined) {
404
+ const session = sessions.get(sessionId);
405
+ if (session && isInitializingSession(session)) enrollInitializerRequest(session, request);
406
+ }
407
+ const remainingMs = getRemainingTimeoutMs(deadlineMs);
408
+ deadlineTimer =
409
+ remainingMs !== undefined
410
+ ? setTimeout(() => controller.abort(new PythonExecutionCancelledError(true)), Math.max(0, remainingMs))
411
+ : undefined;
412
+ deadlineTimer?.unref?.();
413
+
414
+ return {
415
+ options: { ...capturedOptions, signal, deadlineMs },
416
+ request,
417
+ cwd,
418
+ };
419
+ }
420
+
205
421
  function getRemainingTimeoutMs(deadlineMs?: number): number | undefined {
206
422
  if (deadlineMs === undefined) return undefined;
207
423
  return deadlineMs - Date.now();
@@ -233,6 +449,13 @@ function isTimedOutCancellation(error: unknown, signal?: AbortSignal): boolean {
233
449
  return reason instanceof Error ? reason.name === "TimeoutError" : false;
234
450
  }
235
451
 
452
+ function throwIfExecutionCancelled(options: Pick<PythonExecutorOptions, "signal" | "deadlineMs">): void {
453
+ if (options.signal?.aborted) {
454
+ throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
455
+ }
456
+ requireRemainingTimeoutMs(options.deadlineMs);
457
+ }
458
+
236
459
  async function waitForPromiseWithCancellation<T>(
237
460
  promise: Promise<T>,
238
461
  options: Pick<PythonExecutorOptions, "signal" | "deadlineMs">,
@@ -332,8 +555,15 @@ function buildKernelEnv(options: {
332
555
  return Object.keys(env).length > 0 ? env : undefined;
333
556
  }
334
557
 
335
- async function startKernel(cwd: string, options: PythonExecutorOptions): Promise<PythonKernel> {
336
- requireRemainingTimeoutMs(options.deadlineMs);
558
+ async function startKernel(
559
+ cwd: string,
560
+ options: PythonExecutorOptions,
561
+ registration?: {
562
+ onKernelCreated: (kernel: PythonKernel) => void;
563
+ onStartupFailure: (kernel: PythonKernel) => Promise<void>;
564
+ },
565
+ ): Promise<PythonKernel> {
566
+ throwIfExecutionCancelled(options);
337
567
  return await PythonKernel.start({
338
568
  cwd,
339
569
  settings: options.settings,
@@ -341,66 +571,259 @@ async function startKernel(cwd: string, options: PythonExecutorOptions): Promise
341
571
  runtimeOptions: options.runtimeOptions,
342
572
  signal: options.signal,
343
573
  deadlineMs: options.deadlineMs,
574
+ onKernelCreated: registration?.onKernelCreated,
575
+ onStartupFailure: registration?.onStartupFailure,
344
576
  });
345
577
  }
346
578
 
347
- function attachOwner(session: PythonSession, sessionId: string, ownerId: string | undefined): void {
579
+ function attachOwner(
580
+ session: PythonSession | InitializingPythonSession,
581
+ sessionId: string,
582
+ ownerId: string | undefined,
583
+ ): void {
348
584
  if (ownerId !== undefined) {
349
585
  if (session.hasFallbackOwner) {
350
586
  session.ownerIds.delete(sessionId);
587
+ if (!isInitializingSession(session)) session.durableOwnerIds.delete(sessionId);
588
+ removeKernelOwner(session.kernel, sessionId);
351
589
  session.hasFallbackOwner = false;
352
590
  }
353
591
  session.ownerIds.add(ownerId);
592
+ if (!isInitializingSession(session)) session.durableOwnerIds.add(ownerId);
593
+ retainKernelForOwner(session.kernel, ownerId);
354
594
  return;
355
595
  }
356
596
  if (session.hasFallbackOwner || session.ownerIds.size === 0) {
357
597
  session.ownerIds.add(sessionId);
598
+ if (!isInitializingSession(session)) session.durableOwnerIds.add(sessionId);
358
599
  session.hasFallbackOwner = true;
600
+ retainKernelForOwner(session.kernel, sessionId);
601
+ }
602
+ }
603
+
604
+ function enrollInitializerRequest(initializing: InitializingPythonSession, request: ActivePythonRequest): boolean {
605
+ if (request.initializer === initializing) return true;
606
+ if (request.signal?.aborted || initializing.cancelled) return false;
607
+ if (request.sessionId !== initializing.sessionId) return false;
608
+ if (request.initializer) detachInitializerRequest(request, false);
609
+ request.initializer = initializing;
610
+ initializing.requests.add(request);
611
+ if (request.fallbackOwner || !initializing.hasFallbackOwner) {
612
+ attachOwner(initializing, initializing.sessionId, request.fallbackOwner ? undefined : request.ownerId);
613
+ } else if (request.ownerId !== undefined) {
614
+ initializing.ownerIds.add(request.ownerId);
615
+ }
616
+ return true;
617
+ }
618
+
619
+ function initializerRequestOwnerId(request: ActivePythonRequest): string | undefined {
620
+ return request.ownerId;
621
+ }
622
+
623
+ function detachInitializerRequest(request: ActivePythonRequest, timedOut: boolean): void {
624
+ const initializing = request.initializer;
625
+ if (!initializing) return;
626
+ request.initializer = undefined;
627
+ request.initializerAcquired = false;
628
+ initializing.requests.delete(request);
629
+ const ownerId = initializerRequestOwnerId(request);
630
+ if (ownerId !== undefined) {
631
+ let hasOwnerRequest = false;
632
+ for (const other of initializing.requests) {
633
+ if (other.ownerId === ownerId && !other.signal?.aborted) {
634
+ hasOwnerRequest = true;
635
+ break;
636
+ }
637
+ }
638
+ if (!hasOwnerRequest) {
639
+ initializing.ownerIds.delete(ownerId);
640
+ if (request.fallbackOwner || initializing.cleanupFailed) {
641
+ initializing.retirementOwnerIds.add(ownerId);
642
+ }
643
+ if (initializing.ownerIds.size > 0) removeKernelOwner(initializing.kernel, ownerId);
644
+ }
645
+ }
646
+ if (initializing.ownerIds.size !== 0 || initializing.cancelled) return;
647
+ initializing.cancelled = new PythonExecutionCancelledError(timedOut);
648
+ if (initializing.kernel && !initializing.cleanupAttempt && !initializing.cleanupFailed) {
649
+ const [cleanup, start] = prepareRetiringKernelShutdown(initializing.kernel);
650
+ initializing.cleanupAttempt = cleanup;
651
+ start();
652
+ }
653
+ initializing.startupController.abort(initializing.cancelled);
654
+ }
655
+
656
+ function promoteInitializerRequest(request: ActivePythonRequest): void {
657
+ const initializing = request.initializer;
658
+ if (!initializing) return;
659
+ initializing.requests.delete(request);
660
+ request.initializer = undefined;
661
+ }
662
+
663
+ function promoteProvisionalSessionRequest(request: ActivePythonRequest): void {
664
+ const session = request.provisionalSession;
665
+ if (!session) return;
666
+ request.provisionalSession = undefined;
667
+ const ownerId = request.ownerId;
668
+ if (ownerId === undefined) return;
669
+ session.durableOwnerIds.add(ownerId);
670
+ const count = session.provisionalOwnerCounts.get(ownerId) ?? 0;
671
+ if (count <= 1) session.provisionalOwnerCounts.delete(ownerId);
672
+ else session.provisionalOwnerCounts.set(ownerId, count - 1);
673
+ }
674
+
675
+ function detachProvisionalSessionRequest(request: ActivePythonRequest): void {
676
+ const session = request.provisionalSession;
677
+ if (!session) return;
678
+ request.provisionalSession = undefined;
679
+ const ownerId = request.ownerId;
680
+ if (ownerId === undefined) return;
681
+ const count = session.provisionalOwnerCounts.get(ownerId) ?? 0;
682
+ if (count <= 1) session.provisionalOwnerCounts.delete(ownerId);
683
+ else session.provisionalOwnerCounts.set(ownerId, count - 1);
684
+ if (count > 1 || session.durableOwnerIds.has(ownerId)) return;
685
+ if (session.ownerIds.size === 1 && session.ownerIds.has(ownerId)) {
686
+ retainKernelForOwner(session.kernel, ownerId);
687
+ if (sessions.get(session.sessionId) === session) sessions.delete(session.sessionId);
688
+ const cleanup = prepareSessionShutdown(session);
689
+ startSessionShutdown(session);
690
+ void cleanup.catch(error =>
691
+ logger.warn("Python kernel shutdown not confirmed", { kernelId: session.kernel.id, reason: error }),
692
+ );
693
+ return;
694
+ }
695
+ session.ownerIds.delete(ownerId);
696
+ removeKernelOwner(session.kernel, ownerId);
697
+ }
698
+
699
+ function initializingOwnerIds(initializing: InitializingPythonSession): Set<string> {
700
+ return new Set(initializing.ownerIds.size > 0 ? initializing.ownerIds : initializing.retirementOwnerIds);
701
+ }
702
+
703
+ async function shutdownInitializingKernel(
704
+ initializing: InitializingPythonSession,
705
+ kernel: PythonKernel,
706
+ ): Promise<void> {
707
+ try {
708
+ if (initializing.kernel === kernel && initializing.cleanupAttempt) {
709
+ await initializing.cleanupAttempt;
710
+ } else {
711
+ await shutdownOrRetainKernel(kernel, initializingOwnerIds(initializing));
712
+ }
713
+ } catch (error) {
714
+ initializing.cleanupError = error;
715
+ initializing.cleanupFailed = true;
716
+ throw error;
359
717
  }
360
718
  }
361
719
 
362
- async function acquireSession(sessionId: string, cwd: string, options: PythonExecutorOptions): Promise<PythonSession> {
720
+ async function acquireSession(
721
+ sessionId: string,
722
+ cwd: string,
723
+ options: PythonExecutorOptions,
724
+ request: ActivePythonRequest,
725
+ ): Promise<PythonSession> {
726
+ throwIfExecutionCancelled(options);
363
727
  const existing = sessions.get(sessionId);
728
+ if (request.provisionalSession && request.provisionalSession !== existing) {
729
+ detachProvisionalSessionRequest(request);
730
+ }
364
731
  if (existing) {
732
+ if (isInitializingSession(existing)) {
733
+ if (enrollInitializerRequest(existing, request)) {
734
+ if (!request.fallbackOwner) attachOwner(existing, sessionId, options.kernelOwnerId);
735
+ request.initializerAcquired = true;
736
+ }
737
+ } else {
738
+ promoteProvisionalSessionRequest(request);
739
+ attachOwner(existing, sessionId, options.kernelOwnerId);
740
+ }
741
+ if (isInitializingSession(existing) && existing.cleanupFailed && existing.kernel) {
742
+ await shutdownOrRetainKernel(existing.kernel, initializingOwnerIds(existing));
743
+ promoteInitializerRequest(request);
744
+ existing.cleanupFailed = false;
745
+ existing.cleanupError = undefined;
746
+ existing.ownerIds.clear();
747
+ existing.retirementOwnerIds.clear();
748
+ existing.hasFallbackOwner = false;
749
+ if (sessions.get(sessionId) === existing) sessions.delete(sessionId);
750
+ throwIfExecutionCancelled(options);
751
+ return await acquireSession(sessionId, cwd, options, request);
752
+ }
365
753
  let session: PythonSession;
366
754
  if (isInitializingSession(existing)) {
367
755
  try {
368
756
  session = await waitForPromiseWithCancellation(existing.promise, options);
369
757
  } catch (error) {
758
+ if (isCancellationError(error))
759
+ detachInitializerRequest(request, isTimedOutCancellation(error, options.signal));
370
760
  const remainingMs = getRemainingTimeoutMs(options.deadlineMs);
371
- if (
372
- isCancellationError(error) &&
373
- !options.signal?.aborted &&
374
- (remainingMs === undefined || remainingMs > 0)
375
- )
376
- return await acquireSession(sessionId, cwd, options);
761
+ const mayRetry = !options.signal?.aborted && (remainingMs === undefined || remainingMs > 0);
762
+ if (isCancellationError(error) && mayRetry && !existing.cleanupFailed) {
763
+ if (existing.cancelled) await existing.promise.catch(() => undefined);
764
+ return await acquireSession(sessionId, cwd, options, request);
765
+ }
377
766
  throw error;
378
767
  }
379
768
  } else {
380
769
  session = existing;
381
770
  }
382
- attachOwner(session, sessionId, options.kernelOwnerId);
383
- ensurePythonResourceCleanup();
771
+ if (isInitializingSession(existing)) promoteInitializerRequest(request);
384
772
  return session;
385
773
  }
386
774
 
775
+ const startupController = new AbortController();
387
776
  const initializing: InitializingPythonSession = {
388
777
  sessionId,
778
+ startupController,
779
+ requests: new Set(),
780
+ ownerIds: new Set(),
781
+ retirementOwnerIds: new Set(),
782
+ hasFallbackOwner: false,
389
783
  promise: Promise.resolve().then(async () => {
390
- const kernel = await startKernel(cwd, options);
391
- if (initializing.cancelled) {
392
- await shutdownOrRetainKernel(kernel);
393
- throw initializing.cancelled;
784
+ if (initializing.cancelled) throw initializing.cancelled;
785
+ const startupOptions = { ...options, signal: startupController.signal, deadlineMs: undefined };
786
+ const kernel = await startKernel(cwd, startupOptions, {
787
+ onKernelCreated: created => {
788
+ initializing.kernel = created;
789
+ retainKernelForOwners(created, initializingOwnerIds(initializing));
790
+ },
791
+ onStartupFailure: async failed => {
792
+ try {
793
+ await shutdownInitializingKernel(initializing, failed);
794
+ } catch (error) {
795
+ logger.warn("Python kernel shutdown not confirmed", {
796
+ kernelId: failed.id,
797
+ reason: error,
798
+ });
799
+ }
800
+ },
801
+ });
802
+ for (const route of [...initializing.requests]) {
803
+ if (!route.initializerAcquired && route.signal?.aborted) {
804
+ detachInitializerRequest(route, isTimedOutCancellation(route.signal.reason, route.signal));
805
+ }
806
+ }
807
+ if (initializing.cancelled || startupController.signal.aborted) {
808
+ try {
809
+ await shutdownInitializingKernel(initializing, kernel);
810
+ } catch (error) {
811
+ logger.warn("Python kernel shutdown not confirmed", { kernelId: kernel.id, reason: error });
812
+ }
813
+ throw (
814
+ initializing.cancelled ??
815
+ new PythonExecutionCancelledError(
816
+ isTimedOutCancellation(startupController.signal.reason, startupController.signal),
817
+ )
818
+ );
394
819
  }
395
820
  const current = sessions.get(sessionId);
396
821
  if (current !== initializing) {
397
- await shutdownOrRetainKernel(kernel);
398
- const winner = current
399
- ? isInitializingSession(current)
400
- ? await waitForPromiseWithCancellation(current.promise, options)
401
- : current
402
- : undefined;
403
- if (winner) return winner;
822
+ try {
823
+ await shutdownInitializingKernel(initializing, kernel);
824
+ } catch (error) {
825
+ logger.warn("Python kernel shutdown not confirmed", { kernelId: kernel.id, reason: error });
826
+ }
404
827
  throw new PythonExecutionCancelledError(false);
405
828
  }
406
829
  const session: PythonSession = {
@@ -408,21 +831,36 @@ async function acquireSession(sessionId: string, cwd: string, options: PythonExe
408
831
  kernel,
409
832
  kernelInstanceId: crypto.randomUUID(),
410
833
  bridgeCapability: options.bridge?.capability,
411
- ownerIds: new Set(),
412
- hasFallbackOwner: false,
834
+ ownerIds: new Set(initializing.ownerIds),
835
+ durableOwnerIds: new Set(),
836
+ provisionalOwnerCounts: new Map(),
837
+ hasFallbackOwner: initializing.hasFallbackOwner,
413
838
  queue: Promise.resolve(),
414
839
  };
840
+ for (const route of initializing.requests) {
841
+ const ownerId = route.ownerId;
842
+ const acquired = route.initializerAcquired === true;
843
+ route.initializer = undefined;
844
+ route.initializerAcquired = false;
845
+ if (ownerId === undefined || !session.ownerIds.has(ownerId)) continue;
846
+ if (acquired) {
847
+ session.durableOwnerIds.add(ownerId);
848
+ } else {
849
+ route.provisionalSession = session;
850
+ session.provisionalOwnerCounts.set(ownerId, (session.provisionalOwnerCounts.get(ownerId) ?? 0) + 1);
851
+ }
852
+ }
853
+ initializing.requests.clear();
415
854
  sessions.set(sessionId, session);
416
855
  return session;
417
856
  }),
418
857
  };
858
+ if (enrollInitializerRequest(initializing, request)) request.initializerAcquired = true;
859
+ initializingSessions.add(initializing);
419
860
  sessions.set(sessionId, initializing);
420
- ensurePythonResourceCleanup();
421
861
  let cancellationTimer: NodeJS.Timeout | undefined;
422
862
  const retireCancelledInitialization = (timedOut: boolean): void => {
423
- if (initializing.cancelled) return;
424
- initializing.cancelled = new PythonExecutionCancelledError(timedOut);
425
- if (sessions.get(sessionId) === initializing) sessions.delete(sessionId);
863
+ detachInitializerRequest(request, timedOut);
426
864
  };
427
865
  const onAbort = (): void => {
428
866
  options.signal?.removeEventListener("abort", onAbort);
@@ -444,20 +882,21 @@ async function acquireSession(sessionId: string, cwd: string, options: PythonExe
444
882
  }
445
883
  void initializing.promise
446
884
  .finally(() => {
885
+ initializingSessions.delete(initializing);
886
+ if (!initializing.cleanupFailed && sessions.get(sessionId) === initializing) sessions.delete(sessionId);
447
887
  options.signal?.removeEventListener("abort", onAbort);
448
888
  if (cancellationTimer) clearTimeout(cancellationTimer);
449
889
  })
450
890
  .catch(() => undefined);
451
891
  try {
452
892
  const session = await waitForPromiseWithCancellation(initializing.promise, options);
453
- attachOwner(session, sessionId, options.kernelOwnerId);
454
- ensurePythonResourceCleanup();
893
+ promoteInitializerRequest(request);
455
894
  return session;
456
895
  } catch (err) {
457
896
  if (isCancellationError(err)) {
458
897
  retireCancelledInitialization(isTimedOutCancellation(err, options.signal));
459
- await initializing.promise.catch(() => undefined);
460
- } else if (sessions.get(sessionId) === initializing) {
898
+ if (initializing.cancelled) await initializing.promise.catch(() => undefined);
899
+ } else if (sessions.get(sessionId) === initializing && !initializing.cleanupFailed) {
461
900
  sessions.delete(sessionId);
462
901
  }
463
902
  throw err;
@@ -469,22 +908,33 @@ async function replaceSessionKernel(
469
908
  cwd: string,
470
909
  options: PythonExecutorOptions,
471
910
  ): Promise<void> {
911
+ throwIfExecutionCancelled(options);
472
912
  const old = session.kernel;
473
913
  const remaining = getRemainingTimeoutMs(options.deadlineMs);
474
- await old
475
- .shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
476
- .catch(() => undefined);
914
+ const ownerIds = new Set(session.ownerIds);
915
+ await shutdownOrRetainKernel(old, ownerIds, remaining === undefined ? undefined : Math.max(0, remaining));
916
+ throwIfExecutionCancelled(options);
477
917
  if (sessions.get(session.sessionId) !== session) {
478
918
  throw new PythonExecutionCancelledError(false);
479
919
  }
480
- requireRemainingTimeoutMs(options.deadlineMs);
920
+ throwIfExecutionCancelled(options);
481
921
  const bridge = options.bridge;
482
922
  const previousCapability = bridge?.capability;
483
923
  const nextCapability = bridge ? crypto.randomUUID() : undefined;
484
924
  if (bridge && nextCapability) bridge.capability = nextCapability;
485
925
  let next: PythonKernel | undefined;
926
+ let startSucceeded = false;
486
927
  try {
487
- next = await startKernel(cwd, options);
928
+ throwIfExecutionCancelled(options);
929
+ next = await startKernel(cwd, options, {
930
+ onKernelCreated: created => {
931
+ next = created;
932
+ retainKernelForOwners(created, ownerIds);
933
+ },
934
+ onStartupFailure: failed => shutdownOrRetainKernel(failed, ownerIds),
935
+ });
936
+ startSucceeded = true;
937
+ throwIfExecutionCancelled(options);
488
938
  if (sessions.get(session.sessionId) !== session) {
489
939
  throw new PythonExecutionCancelledError(false);
490
940
  }
@@ -492,7 +942,7 @@ async function replaceSessionKernel(
492
942
  session.kernelInstanceId = crypto.randomUUID();
493
943
  session.bridgeCapability = nextCapability;
494
944
  } catch (err) {
495
- await next?.shutdown().catch(() => undefined);
945
+ if (startSucceeded && next) await shutdownOrRetainKernel(next, ownerIds).catch(() => undefined);
496
946
  if (bridge && previousCapability && bridge.capability === nextCapability) {
497
947
  bridge.capability = previousCapability;
498
948
  }
@@ -503,9 +953,26 @@ async function replaceSessionKernel(
503
953
  async function resetSession(sessionId: string): Promise<void> {
504
954
  const existing = sessions.get(sessionId);
505
955
  if (!existing) return;
506
- sessions.delete(sessionId);
507
- const session = isInitializingSession(existing) ? await existing.promise.catch(() => undefined) : existing;
508
- await session?.kernel.shutdown().catch(() => undefined);
956
+ if (isInitializingSession(existing)) {
957
+ existing.cancelled ??= new PythonExecutionCancelledError(false);
958
+ let cleanup: Promise<KernelShutdownResult> | undefined;
959
+ if (existing.kernel) {
960
+ const prepared = prepareRetiringKernelShutdown(existing.kernel);
961
+ cleanup = prepared[0];
962
+ existing.cleanupAttempt = cleanup;
963
+ prepared[1]();
964
+ }
965
+ if (!existing.startupController.signal.aborted) existing.startupController.abort(existing.cancelled);
966
+ await existing.promise.catch(() => undefined);
967
+ if (existing.cleanupFailed) throw existing.cleanupError;
968
+ if (cleanup) await cleanup;
969
+ return;
970
+ }
971
+ const kernel = existing.kernel;
972
+ const shutdown = prepareSessionShutdown(existing);
973
+ startSessionShutdown(existing);
974
+ await shutdown;
975
+ if (sessions.get(sessionId) === existing && existing.kernel === kernel) sessions.delete(sessionId);
509
976
  }
510
977
 
511
978
  async function runQueued<T>(
@@ -533,68 +1000,192 @@ async function runQueued<T>(
533
1000
  // Public dispose entry points
534
1001
  // ---------------------------------------------------------------------------
535
1002
 
536
- export async function disposeAllKernelSessions(): Promise<void> {
537
- const all = [...sessions.entries()];
538
- const retiring = [...retiringKernels];
539
- retiringKernels.clear();
540
- for (const [id, session] of all) {
541
- if (sessions.get(id) === session) sessions.delete(id);
542
- }
543
- const resolved = await Promise.all(
544
- all.map(
545
- async ([id, entry]) =>
546
- [id, isInitializingSession(entry) ? await entry.promise.catch(() => undefined) : entry] as const,
1003
+ export function disposeAllKernelSessions(): Promise<void> {
1004
+ const entries = [...sessions.entries()];
1005
+ const requests = [...activeRequests];
1006
+ const initializing = [...initializingSessions];
1007
+ const targets = new Set<PythonSession>([
1008
+ ...[...entries.map(([, entry]) => entry), ...retiringSessions].filter(
1009
+ (entry): entry is PythonSession => !isInitializingSession(entry),
547
1010
  ),
548
- );
549
- const shutdownTargets = resolved.filter(
550
- (entry): entry is readonly [string, PythonSession] => entry[1] !== undefined,
551
- );
552
- const results = await Promise.allSettled(shutdownTargets.map(([, session]) => session.kernel.shutdown()));
553
- for (let i = 0; i < shutdownTargets.length; i += 1) {
554
- const [id, session] = shutdownTargets[i];
555
- const result = results[i];
556
- if (result.status === "fulfilled" && result.value.confirmed) continue;
557
- const reason = result.status === "rejected" ? result.reason : "not confirmed";
558
- logger.warn("Python kernel shutdown not confirmed", { sessionId: id, reason });
559
- if (!sessions.has(id)) sessions.set(id, session);
560
- }
561
- const retiringResults = await Promise.allSettled(retiring.map(kernel => kernel.shutdown()));
562
- for (let i = 0; i < retiring.length; i += 1) {
563
- const result = retiringResults[i];
564
- if (result.status === "fulfilled" && result.value.confirmed) continue;
565
- retiringKernels.add(retiring[i]);
566
- logger.warn("Python kernel shutdown not confirmed", {
567
- kernelId: retiring[i].id,
568
- reason: result.status === "rejected" ? result.reason : "not confirmed",
569
- });
1011
+ ]);
1012
+ const shutdowns = [...targets].map(session => [session, prepareSessionShutdown(session)] as const);
1013
+ const retiring = [...retiringKernels].map(kernel => [kernel, ...prepareRetiringKernelShutdown(kernel)] as const);
1014
+ for (const request of requests) {
1015
+ for (const kernel of request.kernels) {
1016
+ const cleanup = retiring.find(([retiringKernel]) => retiringKernel === kernel)?.[1];
1017
+ if (cleanup) request.cleanupAttempts.set(kernel, cleanup);
1018
+ }
1019
+ }
1020
+ for (const entry of initializing) {
1021
+ if (!entry.kernel) continue;
1022
+ const cleanup = retiring.find(([kernel]) => kernel === entry.kernel)?.[1];
1023
+ if (cleanup) entry.cleanupAttempt = cleanup;
570
1024
  }
1025
+ for (const [id, entry] of entries) {
1026
+ if (sessions.get(id) === entry) sessions.delete(id);
1027
+ }
1028
+ // Publish every exact physical cleanup future before abort listeners or shutdown callbacks can reenter disposal.
1029
+ for (const request of requests) {
1030
+ if (!request.controller.signal.aborted) request.controller.abort(new PythonExecutionCancelledError(false));
1031
+ }
1032
+ for (const entry of initializing) {
1033
+ entry.cancelled ??= new PythonExecutionCancelledError(false);
1034
+ if (!entry.startupController.signal.aborted) entry.startupController.abort(entry.cancelled);
1035
+ }
1036
+ for (const [session] of shutdowns) startSessionShutdown(session);
1037
+ for (const [, , start] of retiring) start();
1038
+ return (async () => {
1039
+ await Promise.all([
1040
+ ...requests.map(request => request.completion),
1041
+ ...initializing.map(entry => entry.promise.catch(() => undefined)),
1042
+ ]);
1043
+ const failures: unknown[] = initializing.filter(entry => entry.cleanupFailed).map(entry => entry.cleanupError);
1044
+ const results = await Promise.allSettled(shutdowns.map(([, cleanup]) => cleanup));
1045
+ for (let index = 0; index < shutdowns.length; index += 1) {
1046
+ const [session] = shutdowns[index];
1047
+ const result = results[index];
1048
+ if (result.status === "fulfilled" && result.value.confirmed) continue;
1049
+ if (!sessions.has(session.sessionId)) sessions.set(session.sessionId, session);
1050
+ if (result.status === "rejected") failures.push(result.reason);
1051
+ else failures.push(unconfirmedShutdownError(session.kernel));
1052
+ }
1053
+ const retiringResults = await Promise.allSettled(retiring.map(([, cleanup]) => cleanup));
1054
+ for (let index = 0; index < retiring.length; index += 1) {
1055
+ const [kernel] = retiring[index];
1056
+ const result = retiringResults[index];
1057
+ if (result.status === "fulfilled" && result.value.confirmed) continue;
1058
+ retiringKernels.add(kernel);
1059
+ failures.push(result.status === "rejected" ? result.reason : unconfirmedShutdownError(kernel));
1060
+ }
1061
+ if (failures.length) throw failures[0];
1062
+ })();
571
1063
  }
572
1064
 
573
- export async function disposeKernelSessionsByOwner(ownerId: string): Promise<void> {
1065
+ export function disposeKernelSessionsByOwner(ownerId: string): Promise<void> {
1066
+ const requests = [...activeRequests].filter(request => request.ownerId === ownerId);
1067
+ const entries = [...sessions.entries()].filter(([, entry]) => entry.ownerIds.has(ownerId));
1068
+ const initializing = [...initializingSessions].filter(
1069
+ entry => entry.ownerIds.has(ownerId) || entry.retirementOwnerIds.has(ownerId),
1070
+ );
1071
+ const sessionsToRetire = new Set<PythonSession>([
1072
+ ...[...entries.map(([, entry]) => entry), ...retiringSessions].filter(
1073
+ (entry): entry is PythonSession => !isInitializingSession(entry) && entry.ownerIds.has(ownerId),
1074
+ ),
1075
+ ]);
574
1076
  const toShutdown: PythonSession[] = [];
575
- for (const session of [...sessions.values()]) {
576
- if (isInitializingSession(session) || !session.ownerIds.has(ownerId)) continue;
1077
+ const initializersToAbort: InitializingPythonSession[] = [];
1078
+ for (const entry of initializing) {
1079
+ entry.retirementOwnerIds.add(ownerId);
1080
+ let hasOwnerRequest = false;
1081
+ for (const request of entry.requests) {
1082
+ if (request.ownerId === ownerId && !request.signal?.aborted) {
1083
+ hasOwnerRequest = true;
1084
+ break;
1085
+ }
1086
+ }
1087
+ if (!hasOwnerRequest) {
1088
+ entry.ownerIds.delete(ownerId);
1089
+ if (entry.ownerIds.size > 0) removeKernelOwner(entry.kernel, ownerId);
1090
+ }
1091
+ if (![...entry.ownerIds].some(id => id !== ownerId)) {
1092
+ entry.cancelled ??= new PythonExecutionCancelledError(false);
1093
+ initializersToAbort.push(entry);
1094
+ }
1095
+ }
1096
+ for (const session of sessionsToRetire) {
577
1097
  if (session.ownerIds.size === 1) {
578
1098
  toShutdown.push(session);
579
- continue;
1099
+ if (sessions.get(session.sessionId) === session) sessions.delete(session.sessionId);
1100
+ } else {
1101
+ session.ownerIds.delete(ownerId);
1102
+ session.durableOwnerIds.delete(ownerId);
1103
+ removeKernelOwner(session.kernel, ownerId);
580
1104
  }
581
- session.ownerIds.delete(ownerId);
582
1105
  }
583
- for (const session of toShutdown) {
584
- if (sessions.get(session.sessionId) === session) sessions.delete(session.sessionId);
585
- }
586
- const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown()));
587
- for (let i = 0; i < toShutdown.length; i += 1) {
588
- const session = toShutdown[i];
589
- const result = results[i];
590
- if (result.status === "fulfilled" && result.value.confirmed) {
591
- session.ownerIds.delete(ownerId);
1106
+ const shutdowns = toShutdown.map(session => [session, prepareSessionShutdown(session)] as const);
1107
+ const retiring: Array<readonly [PythonKernel, Promise<KernelShutdownResult>, () => void]> = [];
1108
+ for (const kernel of retiringKernels) {
1109
+ const owners = retiringKernelOwners.get(kernel);
1110
+ if (!owners?.has(ownerId)) continue;
1111
+ if (owners.size > 1) {
1112
+ owners.delete(ownerId);
592
1113
  continue;
593
1114
  }
594
- const reason = result.status === "rejected" ? result.reason : "not confirmed";
595
- logger.warn("Python kernel shutdown not confirmed", { sessionId: session.sessionId, reason });
596
- if (!sessions.has(session.sessionId)) sessions.set(session.sessionId, session);
1115
+ retiring.push([kernel, ...prepareRetiringKernelShutdown(kernel)]);
597
1116
  }
1117
+ for (const request of requests) {
1118
+ for (const kernel of request.kernels) {
1119
+ const cleanup = retiring.find(([retiringKernel]) => retiringKernel === kernel)?.[1];
1120
+ if (cleanup) request.cleanupAttempts.set(kernel, cleanup);
1121
+ }
1122
+ }
1123
+ for (const entry of initializing) {
1124
+ if (!entry.kernel) continue;
1125
+ const cleanup = retiring.find(([kernel]) => kernel === entry.kernel)?.[1];
1126
+ if (cleanup) entry.cleanupAttempt = cleanup;
1127
+ }
1128
+ // All cleanup futures are visible before an abort listener can reenter this API.
1129
+ for (const entry of initializersToAbort) {
1130
+ if (!entry.startupController.signal.aborted) entry.startupController.abort(entry.cancelled);
1131
+ }
1132
+ for (const request of requests) {
1133
+ if (!request.controller.signal.aborted) request.controller.abort(new PythonExecutionCancelledError(false));
1134
+ }
1135
+ for (const [session] of shutdowns) startSessionShutdown(session);
1136
+ for (const [, , start] of retiring) start();
1137
+ return (async () => {
1138
+ await Promise.all([
1139
+ ...requests.map(request => request.completion),
1140
+ ...initializing.map(entry => entry.promise.catch(() => undefined)),
1141
+ ]);
1142
+ const failures: unknown[] = [];
1143
+ for (const entry of initializing)
1144
+ if (entry.cleanupFailed) {
1145
+ if (entry.kernel) retainKernelForOwners(entry.kernel, new Set([ownerId]));
1146
+ failures.push(entry.cleanupError);
1147
+ }
1148
+ const results = await Promise.allSettled(shutdowns.map(([, shutdown]) => shutdown));
1149
+ for (let index = 0; index < shutdowns.length; index += 1) {
1150
+ const [session] = shutdowns[index];
1151
+ const result = results[index];
1152
+ if (result.status === "fulfilled" && result.value.confirmed) {
1153
+ session.ownerIds.delete(ownerId);
1154
+ session.durableOwnerIds.delete(ownerId);
1155
+ continue;
1156
+ }
1157
+ if (!sessions.has(session.sessionId)) sessions.set(session.sessionId, session);
1158
+ if (result.status === "rejected") failures.push(result.reason);
1159
+ else failures.push(unconfirmedShutdownError(session.kernel));
1160
+ }
1161
+ const retiringResults = await Promise.allSettled(retiring.map(([, cleanup]) => cleanup));
1162
+ for (let index = 0; index < retiring.length; index += 1) {
1163
+ const [kernel] = retiring[index];
1164
+ const result = retiringResults[index];
1165
+ if (result.status === "fulfilled" && result.value.confirmed) continue;
1166
+ failures.push(result.status === "rejected" ? result.reason : unconfirmedShutdownError(kernel));
1167
+ }
1168
+ for (const [id, entry] of sessions) {
1169
+ if (
1170
+ isInitializingSession(entry) &&
1171
+ entry.retirementOwnerIds.has(ownerId) &&
1172
+ entry.cleanupFailed &&
1173
+ entry.kernel &&
1174
+ !retiringKernels.has(entry.kernel)
1175
+ ) {
1176
+ entry.cleanupFailed = false;
1177
+ entry.cleanupError = undefined;
1178
+ if (sessions.get(id) === entry) sessions.delete(id);
1179
+ }
1180
+ }
1181
+ if (failures.length) throw failures[0];
1182
+ })();
1183
+ }
1184
+
1185
+ function unconfirmedShutdownError(kernel: PythonKernel): Error {
1186
+ const error = new Error(`Python kernel shutdown not confirmed: ${kernel.id}`);
1187
+ error.name = "PythonKernelShutdownUnconfirmedError";
1188
+ return error;
598
1189
  }
599
1190
 
600
1191
  // ---------------------------------------------------------------------------
@@ -607,6 +1198,7 @@ async function executeWithKernel(
607
1198
  options: PythonExecutorOptions | undefined,
608
1199
  ): Promise<PythonResult> {
609
1200
  const settings = options?.settings ?? (await Settings.init());
1201
+ if (options) throwIfExecutionCancelled(options);
610
1202
  const sink = new OutputSink({
611
1203
  onChunk: options?.onChunk,
612
1204
  artifactPath: options?.artifactPath,
@@ -623,16 +1215,19 @@ async function executeWithKernel(
623
1215
  ((event: JsStatusEvent) => {
624
1216
  displayOutputs.push({ type: "status", event });
625
1217
  });
626
- const unregisterBridge =
627
- options?.toolSession && options?.bridgeSessionId && options.bridge
628
- ? registerPyToolBridge(options.bridgeSessionId, options.bridge.capability, {
629
- toolSession: options.toolSession,
630
- signal: options.signal,
631
- emitStatus,
632
- })
633
- : null;
1218
+ let unregisterBridge: (() => void) | null = null;
634
1219
 
635
1220
  try {
1221
+ if (options) throwIfExecutionCancelled(options);
1222
+ unregisterBridge =
1223
+ options?.toolSession && options?.bridgeSessionId && options.bridge
1224
+ ? registerPyToolBridge(options.bridgeSessionId, options.bridge.capability, {
1225
+ toolSession: options.toolSession,
1226
+ signal: options.signal,
1227
+ emitStatus,
1228
+ })
1229
+ : null;
1230
+ if (options) throwIfExecutionCancelled(options);
636
1231
  executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs);
637
1232
  const result = await kernel.execute(code, {
638
1233
  signal: options?.signal,
@@ -640,6 +1235,7 @@ async function executeWithKernel(
640
1235
  onChunk: text => sink.push(text),
641
1236
  onDisplay: output => void displayOutputs.push(output),
642
1237
  });
1238
+ if (options) throwIfExecutionCancelled(options);
643
1239
 
644
1240
  if (result.cancelled) {
645
1241
  // Prefer the caller-configured timeout for the user-facing annotation.
@@ -693,7 +1289,7 @@ async function executeWithKernel(
693
1289
  ...(await sink.dump()),
694
1290
  };
695
1291
  } catch (err) {
696
- if (isCancellationError(err) || options?.signal?.aborted) {
1292
+ if (isCancellationError(err)) {
697
1293
  const timedOut = isTimedOutCancellation(err, options?.signal);
698
1294
  return {
699
1295
  exitCode: undefined,
@@ -714,6 +1310,7 @@ async function executeWithKernel(
714
1310
  }
715
1311
 
716
1312
  async function ensureKernelAvailable(cwd: string, options: PythonExecutorOptions): Promise<void> {
1313
+ throwIfExecutionCancelled(options);
717
1314
  const availability = await checkPythonKernelAvailability(
718
1315
  cwd,
719
1316
  options.runtimeOptions,
@@ -723,6 +1320,7 @@ async function ensureKernelAvailable(cwd: string, options: PythonExecutorOptions
723
1320
  },
724
1321
  options.settings,
725
1322
  );
1323
+ throwIfExecutionCancelled(options);
726
1324
  if (!availability.ok) {
727
1325
  throw new Error(availability.reason ?? "Python kernel unavailable");
728
1326
  }
@@ -730,29 +1328,81 @@ async function ensureKernelAvailable(cwd: string, options: PythonExecutorOptions
730
1328
 
731
1329
  async function ensureToolBridge(options: PythonExecutorOptions): Promise<void> {
732
1330
  if (!options.toolSession || options.bridge) return;
1331
+ throwIfExecutionCancelled(options);
733
1332
  try {
734
1333
  const bridge = await ensurePyToolBridge();
1334
+ throwIfExecutionCancelled(options);
735
1335
  options.bridge = { ...bridge, capability: crypto.randomUUID() };
736
1336
  } catch (err) {
1337
+ if (isCancellationError(err) || options.signal?.aborted) throw err;
737
1338
  logger.warn("Failed to start Python tool bridge", {
738
1339
  error: err instanceof Error ? err.message : String(err),
739
1340
  });
740
1341
  }
741
1342
  }
742
1343
 
743
- async function executePerCall(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
1344
+ async function executePerCall(
1345
+ code: string,
1346
+ cwd: string,
1347
+ options: PythonExecutorOptions,
1348
+ request: ActivePythonRequest,
1349
+ ): Promise<PythonResult> {
1350
+ throwIfExecutionCancelled(options);
744
1351
  if (options.bridge && !options.bridgeSessionId) {
745
1352
  options.bridgeSessionId = `py-bridge:${crypto.randomUUID()}`;
746
1353
  }
747
- const kernel = await startKernel(cwd, options);
1354
+ const ownerIds = new Set(options.kernelOwnerId === undefined ? [] : [options.kernelOwnerId]);
1355
+ let physicalKernel: PythonKernel | undefined;
1356
+ const kernel = await startKernel(cwd, options, {
1357
+ onKernelCreated: created => {
1358
+ physicalKernel = created;
1359
+ request.kernels.add(created);
1360
+ retainKernelForOwners(created, ownerIds);
1361
+ },
1362
+ onStartupFailure: async failed => {
1363
+ const cleanup = request.cleanupAttempts.get(failed);
1364
+ if (cleanup) await cleanup;
1365
+ else await shutdownOrRetainKernel(failed, ownerIds);
1366
+ },
1367
+ });
1368
+ const createdKernel = physicalKernel;
1369
+ if (!createdKernel || createdKernel !== kernel) {
1370
+ await shutdownOrRetainKernel(kernel, ownerIds);
1371
+ throw new Error("Python kernel creation was not registered");
1372
+ }
1373
+ let execution:
1374
+ | { readonly succeeded: true; readonly result: PythonResult }
1375
+ | { readonly succeeded: false; readonly error: unknown };
748
1376
  try {
749
- return await executeWithKernel(kernel, code, options);
750
- } finally {
751
- await kernel.shutdown().catch(() => undefined);
1377
+ throwIfExecutionCancelled(options);
1378
+ execution = { succeeded: true, result: await executeWithKernel(kernel, code, options) };
1379
+ } catch (error) {
1380
+ execution = { succeeded: false, error };
1381
+ }
1382
+
1383
+ let cleanup: { readonly succeeded: true } | { readonly succeeded: false; readonly error: unknown };
1384
+ try {
1385
+ const cleanupAttempt = request.cleanupAttempts.get(createdKernel);
1386
+ if (cleanupAttempt) await cleanupAttempt;
1387
+ else await shutdownOrRetainKernel(createdKernel, ownerIds);
1388
+ cleanup = { succeeded: true };
1389
+ } catch (error) {
1390
+ cleanup = { succeeded: false, error: new PythonKernelCleanupError(createdKernel, error) };
752
1391
  }
1392
+
1393
+ if (!execution.succeeded && !isCancellationError(execution.error)) throw execution.error;
1394
+ if (!cleanup.succeeded) throw cleanup.error;
1395
+ if (!execution.succeeded) throw execution.error;
1396
+ return execution.result;
753
1397
  }
754
1398
 
755
- async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
1399
+ async function executeOnSession(
1400
+ code: string,
1401
+ cwd: string,
1402
+ options: PythonExecutorOptions,
1403
+ request: ActivePythonRequest,
1404
+ ): Promise<PythonResult> {
1405
+ throwIfExecutionCancelled(options);
756
1406
  const sessionId = scopedSessionId(
757
1407
  options.sessionId ?? `session:${cwd}`,
758
1408
  options.settings,
@@ -762,28 +1412,32 @@ async function executeOnSession(code: string, cwd: string, options: PythonExecut
762
1412
  options.bridgeSessionId = sessionId;
763
1413
  }
764
1414
  if (options.reset) {
1415
+ throwIfExecutionCancelled(options);
765
1416
  await resetSession(sessionId);
1417
+ throwIfExecutionCancelled(options);
766
1418
  }
767
- const session = await acquireSession(sessionId, cwd, options);
1419
+ const session = await acquireSession(sessionId, cwd, options, request);
1420
+ throwIfExecutionCancelled(options);
768
1421
  if (options.bridge && session.bridgeCapability) {
769
1422
  options.bridge.capability = session.bridgeCapability;
770
1423
  }
771
1424
  options.onKernelStart?.(session.kernelInstanceId);
772
1425
  return await runQueued(session, options, async () => {
773
- if (options.signal?.aborted) {
774
- throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
775
- }
1426
+ throwIfExecutionCancelled(options);
776
1427
  if (sessions.get(session.sessionId) !== session) {
777
1428
  throw new PythonExecutionCancelledError(false);
778
1429
  }
779
1430
  if (!session.kernel.isAlive()) {
1431
+ throwIfExecutionCancelled(options);
780
1432
  await replaceSessionKernel(session, cwd, options);
1433
+ throwIfExecutionCancelled(options);
781
1434
  if (sessions.get(session.sessionId) !== session) {
782
1435
  throw new PythonExecutionCancelledError(false);
783
1436
  }
784
1437
  options.onKernelStart?.(session.kernelInstanceId);
785
1438
  }
786
1439
  try {
1440
+ throwIfExecutionCancelled(options);
787
1441
  return await executeWithKernel(session.kernel, code, options);
788
1442
  } catch (err) {
789
1443
  if (isCancellationError(err) || options.signal?.aborted) throw err;
@@ -792,7 +1446,9 @@ async function executeOnSession(code: string, cwd: string, options: PythonExecut
792
1446
  throw new PythonExecutionCancelledError(false);
793
1447
  }
794
1448
  // Kernel died during execute. Replace it and retry once on a fresh one.
1449
+ throwIfExecutionCancelled(options);
795
1450
  await replaceSessionKernel(session, cwd, options);
1451
+ throwIfExecutionCancelled(options);
796
1452
  if (sessions.get(session.sessionId) !== session) {
797
1453
  throw new PythonExecutionCancelledError(false);
798
1454
  }
@@ -807,59 +1463,45 @@ export async function executePythonWithKernel(
807
1463
  code: string,
808
1464
  options?: PythonExecutorOptions,
809
1465
  ): Promise<PythonResult> {
810
- return await executeWithKernel(kernel, code, options);
1466
+ const tracked = beginPythonRequest(options);
1467
+ try {
1468
+ return await executeWithKernel(kernel, code, tracked.options);
1469
+ } catch (err) {
1470
+ if (isCancellationError(err)) {
1471
+ return createCancelledPythonResult(
1472
+ isTimedOutCancellation(err, tracked.options.signal),
1473
+ tracked.options.timeoutMs,
1474
+ );
1475
+ }
1476
+ throw err;
1477
+ } finally {
1478
+ tracked.request.finish();
1479
+ }
811
1480
  }
812
1481
 
813
1482
  export async function executePython(code: string, options?: PythonExecutorOptions): Promise<PythonResult> {
814
- const cwd = options?.cwd ?? getProjectDir();
815
- const deadlineMs = getExecutionDeadlineMs(options);
816
- const deadlineController = deadlineMs === undefined ? undefined : new AbortController();
817
- const combinedController = deadlineController && options?.signal ? new AbortController() : undefined;
818
- const forwardAbort = (): void => combinedController?.abort(options?.signal?.reason);
819
- if (combinedController) {
820
- if (options?.signal?.aborted) forwardAbort();
821
- else options?.signal?.addEventListener("abort", forwardAbort, { once: true });
822
- }
823
- const forwardDeadlineAbort = (): void => combinedController?.abort(deadlineController?.signal.reason);
824
- if (combinedController && deadlineController)
825
- deadlineController.signal.addEventListener("abort", forwardDeadlineAbort, { once: true });
826
- const signal = combinedController?.signal ?? deadlineController?.signal ?? options?.signal;
827
- const remainingMs = getRemainingTimeoutMs(deadlineMs);
828
- const deadlineTimer =
829
- deadlineController && remainingMs !== undefined
830
- ? setTimeout(() => deadlineController.abort(new PythonExecutionCancelledError(true)), Math.max(0, remainingMs))
831
- : undefined;
832
- deadlineTimer?.unref();
833
- const executionOptions: PythonExecutorOptions = {
834
- ...(options ?? {}),
835
- signal,
836
- deadlineMs,
837
- };
1483
+ const tracked = beginPythonRequest(options, true);
1484
+ const executionOptions = tracked.options;
1485
+ const cwd = tracked.cwd!;
838
1486
  try {
839
- requireRemainingTimeoutMs(deadlineMs);
840
- if (executionOptions.signal?.aborted) {
841
- throw new PythonExecutionCancelledError(
842
- isTimedOutCancellation(executionOptions.signal.reason, executionOptions.signal),
843
- );
844
- }
1487
+ throwIfExecutionCancelled(executionOptions);
845
1488
  await ensureKernelAvailable(cwd, executionOptions);
1489
+ throwIfExecutionCancelled(executionOptions);
846
1490
  await ensureToolBridge(executionOptions);
1491
+ throwIfExecutionCancelled(executionOptions);
847
1492
 
848
1493
  const kernelMode = executionOptions.kernelMode ?? "session";
849
1494
  if (kernelMode === "per-call") {
850
- return await executePerCall(code, cwd, executionOptions);
1495
+ return await executePerCall(code, cwd, executionOptions, tracked.request);
851
1496
  }
852
- return await executeOnSession(code, cwd, executionOptions);
1497
+ return await executeOnSession(code, cwd, executionOptions, tracked.request);
853
1498
  } catch (err) {
854
- if (isCancellationError(err) || executionOptions.signal?.aborted) {
1499
+ if (err instanceof PythonKernelStartError || err instanceof PythonKernelCleanupError) throw err;
1500
+ if (isCancellationError(err)) {
855
1501
  return createCancelledPythonResult(isTimedOutCancellation(err, executionOptions.signal));
856
1502
  }
857
1503
  throw err;
858
1504
  } finally {
859
- if (deadlineTimer) clearTimeout(deadlineTimer);
860
- if (combinedController) {
861
- options?.signal?.removeEventListener("abort", forwardAbort);
862
- deadlineController?.signal.removeEventListener("abort", forwardDeadlineAbort);
863
- }
1505
+ tracked.request.finish();
864
1506
  }
865
1507
  }