@coool/opencode-adaptive 0.0.0-stage → 1.0.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 (334) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/dist/adapters/codex-capacity/index.d.ts +4 -0
  4. package/dist/adapters/codex-capacity/index.js +5 -0
  5. package/dist/adapters/codex-capacity/index.js.map +1 -0
  6. package/dist/adapters/codex-capacity/parse.d.ts +28 -0
  7. package/dist/adapters/codex-capacity/parse.js +146 -0
  8. package/dist/adapters/codex-capacity/parse.js.map +1 -0
  9. package/dist/adapters/codex-capacity/reading.d.ts +9 -0
  10. package/dist/adapters/codex-capacity/reading.js +45 -0
  11. package/dist/adapters/codex-capacity/reading.js.map +1 -0
  12. package/dist/adapters/codex-capacity/sensor.d.ts +23 -0
  13. package/dist/adapters/codex-capacity/sensor.js +30 -0
  14. package/dist/adapters/codex-capacity/sensor.js.map +1 -0
  15. package/dist/adapters/codex-capacity/types.d.ts +31 -0
  16. package/dist/adapters/codex-capacity/types.js +18 -0
  17. package/dist/adapters/codex-capacity/types.js.map +1 -0
  18. package/dist/adapters/opencode/escalation-tool.d.ts +51 -0
  19. package/dist/adapters/opencode/escalation-tool.js +61 -0
  20. package/dist/adapters/opencode/escalation-tool.js.map +1 -0
  21. package/dist/adapters/opencode/events.d.ts +23 -0
  22. package/dist/adapters/opencode/events.js +68 -0
  23. package/dist/adapters/opencode/events.js.map +1 -0
  24. package/dist/adapters/opencode/hooks.d.ts +34 -0
  25. package/dist/adapters/opencode/hooks.js +29 -0
  26. package/dist/adapters/opencode/hooks.js.map +1 -0
  27. package/dist/adapters/opencode/index.d.ts +16 -0
  28. package/dist/adapters/opencode/index.js +9 -0
  29. package/dist/adapters/opencode/index.js.map +1 -0
  30. package/dist/adapters/opencode/inventory.d.ts +30 -0
  31. package/dist/adapters/opencode/inventory.js +53 -0
  32. package/dist/adapters/opencode/inventory.js.map +1 -0
  33. package/dist/adapters/opencode/report.d.ts +91 -0
  34. package/dist/adapters/opencode/report.js +145 -0
  35. package/dist/adapters/opencode/report.js.map +1 -0
  36. package/dist/adapters/opencode/session-ops.d.ts +39 -0
  37. package/dist/adapters/opencode/session-ops.js +32 -0
  38. package/dist/adapters/opencode/session-ops.js.map +1 -0
  39. package/dist/adapters/opencode/storage.d.ts +15 -0
  40. package/dist/adapters/opencode/storage.js +8 -0
  41. package/dist/adapters/opencode/storage.js.map +1 -0
  42. package/dist/adapters/opencode/tier-tool.d.ts +37 -0
  43. package/dist/adapters/opencode/tier-tool.js +72 -0
  44. package/dist/adapters/opencode/tier-tool.js.map +1 -0
  45. package/dist/adapters/opencode/types.d.ts +101 -0
  46. package/dist/adapters/opencode/types.js +2 -0
  47. package/dist/adapters/opencode/types.js.map +1 -0
  48. package/dist/core/compatibility/gate.d.ts +7 -0
  49. package/dist/core/compatibility/gate.js +51 -0
  50. package/dist/core/compatibility/gate.js.map +1 -0
  51. package/dist/core/compatibility/index.d.ts +2 -0
  52. package/dist/core/compatibility/index.js +3 -0
  53. package/dist/core/compatibility/index.js.map +1 -0
  54. package/dist/core/compatibility/types.d.ts +53 -0
  55. package/dist/core/compatibility/types.js +27 -0
  56. package/dist/core/compatibility/types.js.map +1 -0
  57. package/dist/core/scheduler/admit.d.ts +6 -0
  58. package/dist/core/scheduler/admit.js +154 -0
  59. package/dist/core/scheduler/admit.js.map +1 -0
  60. package/dist/core/scheduler/escalate.d.ts +6 -0
  61. package/dist/core/scheduler/escalate.js +45 -0
  62. package/dist/core/scheduler/escalate.js.map +1 -0
  63. package/dist/core/scheduler/index.d.ts +4 -0
  64. package/dist/core/scheduler/index.js +5 -0
  65. package/dist/core/scheduler/index.js.map +1 -0
  66. package/dist/core/scheduler/mode.d.ts +8 -0
  67. package/dist/core/scheduler/mode.js +10 -0
  68. package/dist/core/scheduler/mode.js.map +1 -0
  69. package/dist/core/scheduler/types.d.ts +136 -0
  70. package/dist/core/scheduler/types.js +75 -0
  71. package/dist/core/scheduler/types.js.map +1 -0
  72. package/dist/core/tier-policy/index.d.ts +3 -0
  73. package/dist/core/tier-policy/index.js +4 -0
  74. package/dist/core/tier-policy/index.js.map +1 -0
  75. package/dist/core/tier-policy/resolve.d.ts +18 -0
  76. package/dist/core/tier-policy/resolve.js +52 -0
  77. package/dist/core/tier-policy/resolve.js.map +1 -0
  78. package/dist/core/tier-policy/tier-list.d.ts +10 -0
  79. package/dist/core/tier-policy/tier-list.js +23 -0
  80. package/dist/core/tier-policy/tier-list.js.map +1 -0
  81. package/dist/core/tier-policy/types.d.ts +86 -0
  82. package/dist/core/tier-policy/types.js +49 -0
  83. package/dist/core/tier-policy/types.js.map +1 -0
  84. package/dist/core/work-unit/index.d.ts +3 -0
  85. package/dist/core/work-unit/index.js +4 -0
  86. package/dist/core/work-unit/index.js.map +1 -0
  87. package/dist/core/work-unit/interruption.d.ts +7 -0
  88. package/dist/core/work-unit/interruption.js +12 -0
  89. package/dist/core/work-unit/interruption.js.map +1 -0
  90. package/dist/core/work-unit/tracker.d.ts +53 -0
  91. package/dist/core/work-unit/tracker.js +156 -0
  92. package/dist/core/work-unit/tracker.js.map +1 -0
  93. package/dist/core/work-unit/types.d.ts +151 -0
  94. package/dist/core/work-unit/types.js +23 -0
  95. package/dist/core/work-unit/types.js.map +1 -0
  96. package/dist/execution/continuation/execute.d.ts +18 -0
  97. package/dist/execution/continuation/execute.js +31 -0
  98. package/dist/execution/continuation/execute.js.map +1 -0
  99. package/dist/execution/continuation/index.d.ts +4 -0
  100. package/dist/execution/continuation/index.js +4 -0
  101. package/dist/execution/continuation/index.js.map +1 -0
  102. package/dist/execution/continuation/plan.d.ts +12 -0
  103. package/dist/execution/continuation/plan.js +18 -0
  104. package/dist/execution/continuation/plan.js.map +1 -0
  105. package/dist/execution/continuation/prompts.d.ts +7 -0
  106. package/dist/execution/continuation/prompts.js +8 -0
  107. package/dist/execution/continuation/prompts.js.map +1 -0
  108. package/dist/execution/continuation/types.d.ts +68 -0
  109. package/dist/execution/continuation/types.js +15 -0
  110. package/dist/execution/continuation/types.js.map +1 -0
  111. package/dist/execution/escalation/index.d.ts +1 -0
  112. package/dist/execution/escalation/index.js +2 -0
  113. package/dist/execution/escalation/index.js.map +1 -0
  114. package/dist/execution/escalation/types.d.ts +30 -0
  115. package/dist/execution/escalation/types.js +49 -0
  116. package/dist/execution/escalation/types.js.map +1 -0
  117. package/dist/execution/index.d.ts +4 -0
  118. package/dist/execution/index.js +5 -0
  119. package/dist/execution/index.js.map +1 -0
  120. package/dist/execution/parking/index.d.ts +2 -0
  121. package/dist/execution/parking/index.js +2 -0
  122. package/dist/execution/parking/index.js.map +1 -0
  123. package/dist/execution/parking/plan.d.ts +39 -0
  124. package/dist/execution/parking/plan.js +34 -0
  125. package/dist/execution/parking/plan.js.map +1 -0
  126. package/dist/execution/retry/decide.d.ts +29 -0
  127. package/dist/execution/retry/decide.js +75 -0
  128. package/dist/execution/retry/decide.js.map +1 -0
  129. package/dist/execution/retry/index.d.ts +2 -0
  130. package/dist/execution/retry/index.js +3 -0
  131. package/dist/execution/retry/index.js.map +1 -0
  132. package/dist/execution/retry/types.d.ts +64 -0
  133. package/dist/execution/retry/types.js +9 -0
  134. package/dist/execution/retry/types.js.map +1 -0
  135. package/dist/gui-experimental.cjs +1 -0
  136. package/dist/gui.d.ts +16 -0
  137. package/dist/gui.js +73 -0
  138. package/dist/gui.js.map +1 -0
  139. package/dist/health/capacity/index.d.ts +3 -0
  140. package/dist/health/capacity/index.js +4 -0
  141. package/dist/health/capacity/index.js.map +1 -0
  142. package/dist/health/capacity/merge.d.ts +57 -0
  143. package/dist/health/capacity/merge.js +154 -0
  144. package/dist/health/capacity/merge.js.map +1 -0
  145. package/dist/health/capacity/registry.d.ts +40 -0
  146. package/dist/health/capacity/registry.js +106 -0
  147. package/dist/health/capacity/registry.js.map +1 -0
  148. package/dist/health/capacity/types.d.ts +73 -0
  149. package/dist/health/capacity/types.js +6 -0
  150. package/dist/health/capacity/types.js.map +1 -0
  151. package/dist/health/circuit-breaker/breaker.d.ts +91 -0
  152. package/dist/health/circuit-breaker/breaker.js +150 -0
  153. package/dist/health/circuit-breaker/breaker.js.map +1 -0
  154. package/dist/health/circuit-breaker/index.d.ts +2 -0
  155. package/dist/health/circuit-breaker/index.js +3 -0
  156. package/dist/health/circuit-breaker/index.js.map +1 -0
  157. package/dist/health/circuit-breaker/types.d.ts +52 -0
  158. package/dist/health/circuit-breaker/types.js +13 -0
  159. package/dist/health/circuit-breaker/types.js.map +1 -0
  160. package/dist/health/classifier/classify.d.ts +8 -0
  161. package/dist/health/classifier/classify.js +245 -0
  162. package/dist/health/classifier/classify.js.map +1 -0
  163. package/dist/health/classifier/index.d.ts +2 -0
  164. package/dist/health/classifier/index.js +3 -0
  165. package/dist/health/classifier/index.js.map +1 -0
  166. package/dist/health/classifier/types.d.ts +71 -0
  167. package/dist/health/classifier/types.js +27 -0
  168. package/dist/health/classifier/types.js.map +1 -0
  169. package/dist/health/index.d.ts +4 -0
  170. package/dist/health/index.js +5 -0
  171. package/dist/health/index.js.map +1 -0
  172. package/dist/health/resource-health/index.d.ts +4 -0
  173. package/dist/health/resource-health/index.js +5 -0
  174. package/dist/health/resource-health/index.js.map +1 -0
  175. package/dist/health/resource-health/key.d.ts +18 -0
  176. package/dist/health/resource-health/key.js +54 -0
  177. package/dist/health/resource-health/key.js.map +1 -0
  178. package/dist/health/resource-health/registry.d.ts +46 -0
  179. package/dist/health/resource-health/registry.js +276 -0
  180. package/dist/health/resource-health/registry.js.map +1 -0
  181. package/dist/health/resource-health/transitions.d.ts +9 -0
  182. package/dist/health/resource-health/transitions.js +157 -0
  183. package/dist/health/resource-health/transitions.js.map +1 -0
  184. package/dist/health/resource-health/types.d.ts +122 -0
  185. package/dist/health/resource-health/types.js +44 -0
  186. package/dist/health/resource-health/types.js.map +1 -0
  187. package/dist/index.d.ts +11 -0
  188. package/dist/index.js +105 -0
  189. package/dist/index.js.map +1 -0
  190. package/dist/plugin-id.d.ts +2 -0
  191. package/dist/plugin-id.js +3 -0
  192. package/dist/plugin-id.js.map +1 -0
  193. package/dist/rpc/definition.d.ts +123 -0
  194. package/dist/rpc/definition.js +130 -0
  195. package/dist/rpc/definition.js.map +1 -0
  196. package/dist/rpc/handlers.d.ts +53 -0
  197. package/dist/rpc/handlers.js +125 -0
  198. package/dist/rpc/handlers.js.map +1 -0
  199. package/dist/rpc/status.d.ts +42 -0
  200. package/dist/rpc/status.js +45 -0
  201. package/dist/rpc/status.js.map +1 -0
  202. package/dist/rpc.d.ts +10 -0
  203. package/dist/rpc.js +8 -0
  204. package/dist/rpc.js.map +1 -0
  205. package/dist/state/capacity/index.d.ts +3 -0
  206. package/dist/state/capacity/index.js +4 -0
  207. package/dist/state/capacity/index.js.map +1 -0
  208. package/dist/state/capacity/parse.d.ts +2 -0
  209. package/dist/state/capacity/parse.js +122 -0
  210. package/dist/state/capacity/parse.js.map +1 -0
  211. package/dist/state/capacity/store.d.ts +18 -0
  212. package/dist/state/capacity/store.js +78 -0
  213. package/dist/state/capacity/store.js.map +1 -0
  214. package/dist/state/capacity/types.d.ts +37 -0
  215. package/dist/state/capacity/types.js +11 -0
  216. package/dist/state/capacity/types.js.map +1 -0
  217. package/dist/state/health/index.d.ts +4 -0
  218. package/dist/state/health/index.js +4 -0
  219. package/dist/state/health/index.js.map +1 -0
  220. package/dist/state/health/parse.d.ts +2 -0
  221. package/dist/state/health/parse.js +137 -0
  222. package/dist/state/health/parse.js.map +1 -0
  223. package/dist/state/health/store.d.ts +17 -0
  224. package/dist/state/health/store.js +69 -0
  225. package/dist/state/health/store.js.map +1 -0
  226. package/dist/state/health/types.d.ts +23 -0
  227. package/dist/state/health/types.js +5 -0
  228. package/dist/state/health/types.js.map +1 -0
  229. package/dist/state/parked-work/index.d.ts +4 -0
  230. package/dist/state/parked-work/index.js +4 -0
  231. package/dist/state/parked-work/index.js.map +1 -0
  232. package/dist/state/parked-work/parse.d.ts +6 -0
  233. package/dist/state/parked-work/parse.js +83 -0
  234. package/dist/state/parked-work/parse.js.map +1 -0
  235. package/dist/state/parked-work/store.d.ts +17 -0
  236. package/dist/state/parked-work/store.js +98 -0
  237. package/dist/state/parked-work/store.js.map +1 -0
  238. package/dist/state/parked-work/types.d.ts +41 -0
  239. package/dist/state/parked-work/types.js +7 -0
  240. package/dist/state/parked-work/types.js.map +1 -0
  241. package/dist/state/receipts/bound.d.ts +14 -0
  242. package/dist/state/receipts/bound.js +77 -0
  243. package/dist/state/receipts/bound.js.map +1 -0
  244. package/dist/state/receipts/index.d.ts +6 -0
  245. package/dist/state/receipts/index.js +5 -0
  246. package/dist/state/receipts/index.js.map +1 -0
  247. package/dist/state/receipts/parse.d.ts +6 -0
  248. package/dist/state/receipts/parse.js +226 -0
  249. package/dist/state/receipts/parse.js.map +1 -0
  250. package/dist/state/receipts/store.d.ts +34 -0
  251. package/dist/state/receipts/store.js +148 -0
  252. package/dist/state/receipts/store.js.map +1 -0
  253. package/dist/state/receipts/types.d.ts +61 -0
  254. package/dist/state/receipts/types.js +12 -0
  255. package/dist/state/receipts/types.js.map +1 -0
  256. package/dist/state/session-modes/index.d.ts +4 -0
  257. package/dist/state/session-modes/index.js +4 -0
  258. package/dist/state/session-modes/index.js.map +1 -0
  259. package/dist/state/session-modes/parse.d.ts +8 -0
  260. package/dist/state/session-modes/parse.js +62 -0
  261. package/dist/state/session-modes/parse.js.map +1 -0
  262. package/dist/state/session-modes/store.d.ts +20 -0
  263. package/dist/state/session-modes/store.js +87 -0
  264. package/dist/state/session-modes/store.js.map +1 -0
  265. package/dist/state/session-modes/types.d.ts +41 -0
  266. package/dist/state/session-modes/types.js +7 -0
  267. package/dist/state/session-modes/types.js.map +1 -0
  268. package/dist/state/settings/index.d.ts +4 -0
  269. package/dist/state/settings/index.js +4 -0
  270. package/dist/state/settings/index.js.map +1 -0
  271. package/dist/state/settings/parse.d.ts +33 -0
  272. package/dist/state/settings/parse.js +261 -0
  273. package/dist/state/settings/parse.js.map +1 -0
  274. package/dist/state/settings/store.d.ts +21 -0
  275. package/dist/state/settings/store.js +65 -0
  276. package/dist/state/settings/store.js.map +1 -0
  277. package/dist/state/settings/types.d.ts +62 -0
  278. package/dist/state/settings/types.js +12 -0
  279. package/dist/state/settings/types.js.map +1 -0
  280. package/dist/state/tier-requests/index.d.ts +4 -0
  281. package/dist/state/tier-requests/index.js +4 -0
  282. package/dist/state/tier-requests/index.js.map +1 -0
  283. package/dist/state/tier-requests/parse.d.ts +3 -0
  284. package/dist/state/tier-requests/parse.js +53 -0
  285. package/dist/state/tier-requests/parse.js.map +1 -0
  286. package/dist/state/tier-requests/store.d.ts +27 -0
  287. package/dist/state/tier-requests/store.js +96 -0
  288. package/dist/state/tier-requests/store.js.map +1 -0
  289. package/dist/state/tier-requests/types.d.ts +30 -0
  290. package/dist/state/tier-requests/types.js +7 -0
  291. package/dist/state/tier-requests/types.js.map +1 -0
  292. package/dist/supervisor/index.d.ts +4 -0
  293. package/dist/supervisor/index.js +4 -0
  294. package/dist/supervisor/index.js.map +1 -0
  295. package/dist/supervisor/log.d.ts +10 -0
  296. package/dist/supervisor/log.js +14 -0
  297. package/dist/supervisor/log.js.map +1 -0
  298. package/dist/supervisor/supervisor.d.ts +86 -0
  299. package/dist/supervisor/supervisor.js +1629 -0
  300. package/dist/supervisor/supervisor.js.map +1 -0
  301. package/dist/supervisor/types.d.ts +49 -0
  302. package/dist/supervisor/types.js +3 -0
  303. package/dist/supervisor/types.js.map +1 -0
  304. package/dist/supervisor/view.d.ts +3 -0
  305. package/dist/supervisor/view.js +4 -0
  306. package/dist/supervisor/view.js.map +1 -0
  307. package/dist/tui.d.ts +15 -0
  308. package/dist/tui.js +256 -0
  309. package/dist/tui.js.map +1 -0
  310. package/dist/ui/gui/api.d.ts +17 -0
  311. package/dist/ui/gui/api.js +21 -0
  312. package/dist/ui/gui/api.js.map +1 -0
  313. package/dist/ui/gui/view.d.ts +26 -0
  314. package/dist/ui/gui/view.js +49 -0
  315. package/dist/ui/gui/view.js.map +1 -0
  316. package/dist/ui/tui/controller.d.ts +97 -0
  317. package/dist/ui/tui/controller.js +415 -0
  318. package/dist/ui/tui/controller.js.map +1 -0
  319. package/dist/ui/tui/menu.d.ts +55 -0
  320. package/dist/ui/tui/menu.js +176 -0
  321. package/dist/ui/tui/menu.js.map +1 -0
  322. package/dist/ui/tui/options.d.ts +19 -0
  323. package/dist/ui/tui/options.js +17 -0
  324. package/dist/ui/tui/options.js.map +1 -0
  325. package/dist/ui/tui/receipts.d.ts +21 -0
  326. package/dist/ui/tui/receipts.js +112 -0
  327. package/dist/ui/tui/receipts.js.map +1 -0
  328. package/dist/ui/tui/status.d.ts +22 -0
  329. package/dist/ui/tui/status.js +92 -0
  330. package/dist/ui/tui/status.js.map +1 -0
  331. package/dist/ui/tui/tiers.d.ts +51 -0
  332. package/dist/ui/tui/tiers.js +108 -0
  333. package/dist/ui/tui/tiers.js.map +1 -0
  334. package/package.json +85 -4
@@ -0,0 +1,1629 @@
1
+ var _a;
2
+ /**
3
+ * The supervisor (ADP-025..ADP-032): owns all state stores and glues the pure
4
+ * core to the host reports. It contains no policy of its own — every decision
5
+ * comes from the pure scheduler/retry/tier modules — and it applies the two
6
+ * wiring contracts: fail-open (ADP-031, internal failures log and defer) and
7
+ * Observe ≡ Control (the same admission path, only the mode wrapper differs).
8
+ *
9
+ * Wiring decisions recorded here (see docs/ARCHITECTURE.md):
10
+ * - admission runs only on `execution-started` (a safe boundary); auxiliary
11
+ * per-request routing is not applied in v1 because switching the model for
12
+ * an auxiliary request would also move the primary model.
13
+ * - park records arise only from owned-recovery failures and resume
14
+ * boundaries; admission cannot hold an already-started episode, so an
15
+ * admission `park` decision is recorded as a receipt without a parked
16
+ * record (the episode proceeds on OpenCode's own model).
17
+ * - retry-hook veto decisions are not receipts (they are not scheduler
18
+ * decisions); admission receipts plus outcome attachment cover ADP-030.
19
+ * - interruption reasons outside the known vocabulary leave the unit open
20
+ * (a later episode supersedes it); no outcome is ever fabricated.
21
+ * - escalation (ADP-015..017) triggers only on capability-classified unit
22
+ * failures (context overflow, content policy); infrastructure failures
23
+ * never escalate, and the per-task budget is finite.
24
+ */
25
+ import { WorkUnitTracker } from "../core/work-unit/tracker.js";
26
+ import { sameModelRef, } from "../core/work-unit/types.js";
27
+ import { INTERRUPT_REASONS } from "../core/work-unit/interruption.js";
28
+ import { resolveRouting } from "../core/tier-policy/resolve.js";
29
+ import { isTierName, nextTier } from "../core/tier-policy/types.js";
30
+ import { admit, forMode as schedulerForMode, planEscalation, schedulerHealthKey, } from "../core/scheduler/index.js";
31
+ import { MAX_ESCALATION_EVIDENCE } from "../core/scheduler/types.js";
32
+ import { checkCompatibility } from "../core/compatibility/gate.js";
33
+ import { classifyFailure, CapacityRegistry, ResourceHealthRegistry } from "../health/index.js";
34
+ import { sweepRecord } from "../health/resource-health/transitions.js";
35
+ import { HealthRecordStore, HEALTH_KEY } from "../state/health/index.js";
36
+ import { CapacityReadingStore, CAPACITY_KEY } from "../state/capacity/index.js";
37
+ import { mergeReadings } from "../health/capacity/merge.js";
38
+ import { resourceChain, resourceKeyID } from "../health/resource-health/key.js";
39
+ import { SettingsStore, SETTINGS_KEY } from "../state/settings/index.js";
40
+ import { SessionModeStore, SESSION_MODES_KEY } from "../state/session-modes/index.js";
41
+ import { TierRequestStore, TIER_REQUESTS_KEY } from "../state/tier-requests/index.js";
42
+ import { ReceiptStore, RECEIPTS_KEY } from "../state/receipts/index.js";
43
+ import { buildStatus } from "../rpc/status.js";
44
+ import { RECEIPTS_VIEW_LIMIT } from "./view.js";
45
+ import { ParkedWorkStore, PARKED_KEY } from "../state/parked-work/index.js";
46
+ import { decideRetry, executeContinuation, forMode as retryForMode, planContinuation, selectResumable, } from "../execution/index.js";
47
+ import { escalationDetail } from "../execution/escalation/index.js";
48
+ import { listCandidates, sessionOps, storageSeam } from "../adapters/opencode/index.js";
49
+ import { CodexCapacitySensor } from "../adapters/codex-capacity/index.js";
50
+ import { logFailOpen } from "./log.js";
51
+ import { CODEX_PROVIDER_ID } from "./types.js";
52
+ import { TIERS } from "../core/tier-policy/types.js";
53
+ /** Which tiers currently have configured resources (ADP-005). */
54
+ function configuredTiers(settings) {
55
+ return TIERS.filter((tier) => settings.tiers[tier].length > 0);
56
+ }
57
+ /** Narrow a wire interrupt reason to the known vocabulary. */
58
+ function interruptReason(reason) {
59
+ return INTERRUPT_REASONS.includes(reason)
60
+ ? reason
61
+ : undefined;
62
+ }
63
+ /**
64
+ * Failure categories that count as structured capability evidence (ADP-015).
65
+ * The remaining evidence kinds (agent request, reviewer rejection) have no
66
+ * structured host source in v2 and are never inferred from free text.
67
+ */
68
+ const CAPABILITY_EVIDENCE_CATEGORIES = [
69
+ "context_overflow",
70
+ "content_policy",
71
+ ];
72
+ /** Finite per-task escalation budget (ADP-017). */
73
+ const MAX_ESCALATIONS_PER_TASK = 2;
74
+ /**
75
+ * The recovery's post-settle guard aborted it (the session went OFF or the
76
+ * user interrupted while the host settle was in flight). Callers use it to
77
+ * skip the ordinary ops-failure degradation (parking) — an aborted recovery
78
+ * is a steering change, not an infrastructure failure.
79
+ */
80
+ export class RecoveryAbortedError extends Error {
81
+ constructor(sessionID) {
82
+ super(`recovery aborted for session ${sessionID}`);
83
+ this.name = "RecoveryAbortedError";
84
+ }
85
+ }
86
+ export class Supervisor {
87
+ #options;
88
+ #settings;
89
+ #receipts;
90
+ #parked;
91
+ #tracker = new WorkUnitTracker();
92
+ #health;
93
+ #healthRecords;
94
+ #capacityStore;
95
+ #sessionModes;
96
+ #capacity;
97
+ #codex;
98
+ #vetoes = new Map();
99
+ /**
100
+ * Escalations spent per session on the current task (one user prompt and
101
+ * the owned continuations it spawns). Keyed by session, not unit: every
102
+ * escalation continues on a NEW unit, so a per-unit count would restart
103
+ * the budget at each step. A fresh user prompt starts a new task.
104
+ */
105
+ #escalations = new Map();
106
+ #admissionTiers = new Map();
107
+ /** Recorded escalation-tool requests per unit (ADP-015 agent self-reports). */
108
+ #escalationEvidence = new Map();
109
+ /** Last agent observed per session (role-hint routing evidence, ADP-002). */
110
+ // Bounded like every other per-session map: long-lived hosts meet many
111
+ // short-lived subagent sessions, and agent hints for long-gone sessions
112
+ // are worthless. Insertion order gives the eviction (oldest hint first).
113
+ static MAX_AGENT_HINTS = 1024;
114
+ #agents = new Map();
115
+ /**
116
+ * Per-session tier requests (agent-declared via the adaptive_tier tool):
117
+ * the request is STICKY — it steers the session's next and following
118
+ * admissions until cleared or the session dies — because an agent works
119
+ * across many units in one task. Persisted like the session-mode
120
+ * overrides: the host's double plugin instance (hooks vs RPC) must not
121
+ * strand a request on the instance that never routes.
122
+ */
123
+ #tierRequests;
124
+ /**
125
+ * Sessions whose last model change was the user's own pick
126
+ * (`selected-external`): prompt admission does not re-route them —
127
+ * switching would undo the user's `/model` choice (ADP-028). Cleared by an
128
+ * adaptive switch, a tier request, or a mode change; a standing settings
129
+ * pin overrides the suppression (the pin is manual intent too).
130
+ */
131
+ static MAX_MANUAL_SELECTIONS = 1024;
132
+ #manualSelections = new Map();
133
+ /**
134
+ * Sessions with an owned continuation prompt in flight: the prompt hook
135
+ * will fire for it, but that prompt belongs to the active unit — it must
136
+ * not re-admit (prevents an admission ping-pong).
137
+ */
138
+ #continuationPending = new Set();
139
+ /**
140
+ * Depth of continuation prompts whose prompt hook has not run yet (or whose
141
+ * host call is still in flight). While a session is inside this window, a
142
+ * unit close must not clear the ownership mark: the close event of the
143
+ * failed unit is pumped concurrently with the continuation's own
144
+ * beforePrompt → prompt-hook sequence, and clearing the mark there would
145
+ * make the continuation re-admit (admission ping-pong).
146
+ */
147
+ #continuationsInFlight = new Map();
148
+ /**
149
+ * Sessions with an owned recovery in flight (between veto/resume and the
150
+ * delivered continuation prompt). The host settle inside it can take
151
+ * unbounded time; an interruption arriving during that window must abort
152
+ * the stale decision instead of acting after it.
153
+ */
154
+ #recoveryPending = new Set();
155
+ /** Interruptions observed while a recovery was pending on the session. */
156
+ #recoveryInterrupted = new Set();
157
+ /**
158
+ * Half-open probe tickets by session: a probe admitted while computing the
159
+ * recovery for a veto belongs to the continuation episode that follows, so
160
+ * the ticket is parked here until that unit opens and takes it.
161
+ */
162
+ #sessionProbeTickets = new Map();
163
+ /**
164
+ * Half-open probe tickets by unit: the unit admitted as (or carrying) the
165
+ * breaker's single probe. Its termination reports the probe outcome — a
166
+ * success closes the breaker, a failure reopens it, an interruption re-arms
167
+ * it — otherwise a lost probe would wedge the resource in half_open.
168
+ */
169
+ #probeTickets = new Map();
170
+ /**
171
+ * Per-unit dedup for breaker failure evidence (ADP-019). One failed attempt
172
+ * surfaces through up to three host signals — the pumped `http.response`
173
+ * event, the `retry` hook (the same request), and the terminal
174
+ * `execution-failed` (the last attempt) — and counting each would trip the
175
+ * breaker after ~2 real failures instead of `failureThreshold`. The exact
176
+ * arrival order of a pair is not guaranteed (the retry hook runs in the
177
+ * request lifecycle, the event pump lags), so the dedup is order-symmetric:
178
+ * the first signal of a pair counts and arms, the matching counterpart
179
+ * consumes it, a completed pair closes the attempt, and the terminal
180
+ * repeats only when the last attempt left no live evidence. A same-category
181
+ * observation with nothing armed is a NEW attempt (consecutive http-only
182
+ * failures each count). Cleared at unit close.
183
+ */
184
+ #attemptFailures = new Map();
185
+ /**
186
+ * Session → tier admitted by the prompt hook for the unit that is about to
187
+ * open. Consumed by execution-started, which then skips re-routing (the
188
+ * model is already applied to the very turn that triggered admission).
189
+ */
190
+ #promptAdmission = new Map();
191
+ /**
192
+ * Tier intent for an owned continuation (retry recovery, escalation,
193
+ * parked resume) set before its prompt is delivered. The continuation's
194
+ * prompt hook re-arms the prompt-admission marker from this so the
195
+ * continuation's execution-started skips re-routing — a fresh admission
196
+ * would flip an escalated episode back to the preferred model mid-unit
197
+ * (a switch only lands on the next turn). Cleared when the continuation
198
+ * unit opens, its prompt is rejected, or the session dies.
199
+ */
200
+ #continuationTiers = new Map();
201
+ #ready;
202
+ #ops;
203
+ constructor(options) {
204
+ this.#options = options;
205
+ this.#health = new ResourceHealthRegistry(options.clock);
206
+ this.#capacity = new CapacityRegistry(options.clock);
207
+ this.#codex = new CodexCapacitySensor({ providerID: CODEX_PROVIDER_ID });
208
+ this.#capacity.register(this.#codex);
209
+ this.#settings = new SettingsStore(storageSeam(SETTINGS_KEY, options.storage));
210
+ this.#receipts = new ReceiptStore(storageSeam(RECEIPTS_KEY, options.storage));
211
+ this.#parked = new ParkedWorkStore(storageSeam(PARKED_KEY, options.storage));
212
+ this.#healthRecords = new HealthRecordStore(storageSeam(HEALTH_KEY, options.storage));
213
+ this.#capacityStore = new CapacityReadingStore(storageSeam(CAPACITY_KEY, options.storage));
214
+ this.#sessionModes = new SessionModeStore(storageSeam(SESSION_MODES_KEY, options.storage), options.clock);
215
+ this.#tierRequests = new TierRequestStore(storageSeam(TIER_REQUESTS_KEY, options.storage), options.clock);
216
+ this.#ops = sessionOps(options.session, {
217
+ beforeSwitch: (sessionID, model) => {
218
+ this.#tracker.noteOwnSwitch(sessionID, model);
219
+ },
220
+ beforePrompt: (sessionID) => {
221
+ this.#continuationPending.add(sessionID);
222
+ this.#continuationsInFlight.set(sessionID, (this.#continuationsInFlight.get(sessionID) ?? 0) + 1);
223
+ },
224
+ afterPrompt: (sessionID, delivered) => {
225
+ // sessionOps.prompt calls this only after its own beforePrompt, so
226
+ // the entry is always present; the assertion avoids a default branch
227
+ // no caller can reach.
228
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
229
+ const depth = this.#continuationsInFlight.get(sessionID);
230
+ if (depth <= 1) {
231
+ this.#continuationsInFlight.delete(sessionID);
232
+ if (!delivered) {
233
+ // The last in-flight owned prompt never reached the host's hook:
234
+ // its ownership mark would swallow the next user prompt's
235
+ // admission, so it is dropped here (the hook can no longer fire).
236
+ // The continuation's tier intent dies with it — no episode will
237
+ // open to carry it.
238
+ this.#continuationPending.delete(sessionID);
239
+ this.#continuationTiers.delete(sessionID);
240
+ }
241
+ }
242
+ else {
243
+ this.#continuationsInFlight.set(sessionID, depth - 1);
244
+ }
245
+ },
246
+ });
247
+ // Durable state loads before the first report is handled: a later load
248
+ // must never clobber decisions recorded in the meantime.
249
+ const loadState = async () => {
250
+ await this.#receipts.load();
251
+ await this.#parked.load();
252
+ // Unit ids are durable keys (`${sessionID}#${episode}`) persisted in
253
+ // receipts and parked work. Seed the tracker's per-session episode
254
+ // counters above every persisted id: after a restart the counters
255
+ // must never re-mint a colliding id, which would replace a parked
256
+ // record (the store parks one record per id) and misattribute
257
+ // receipt outcomes.
258
+ const floors = new Map();
259
+ const bump = (unitID) => {
260
+ if (unitID === undefined)
261
+ return;
262
+ const cut = unitID.lastIndexOf("#");
263
+ if (cut <= 0)
264
+ return;
265
+ const sessionID = unitID.slice(0, cut);
266
+ const episode = Number(unitID.slice(cut + 1));
267
+ if (!Number.isInteger(episode) || episode <= 0)
268
+ return;
269
+ if (episode > (floors.get(sessionID) ?? 0))
270
+ floors.set(sessionID, episode);
271
+ };
272
+ for (const receipt of this.#receipts.snapshot())
273
+ bump(receipt.unitID);
274
+ for (const record of this.#parked.list())
275
+ bump(record.id);
276
+ this.#tracker.seedEpisodes(floors);
277
+ // Health records hydrate the registry: the host's TUI-mode plugin
278
+ // reconciliation can serve RPC from an instance whose hooks (event
279
+ // pump) live on another instance — persisted failures are the only
280
+ // health that instance can see, and a fresh instance must also assess
281
+ // persisted failures before routing instead of starting from empty.
282
+ await this.#healthRecords.load();
283
+ this.#health.hydrate(this.#healthRecords.snapshot());
284
+ // Session-mode overrides hydrate the same way: a restart (or the
285
+ // host's double-instance reconciliation) must keep steering sessions
286
+ // under the modes the user set for them.
287
+ await this.#sessionModes.load();
288
+ // Tier requests hydrate the same way: a tier request recorded through
289
+ // the host's other plugin instance (or before a restart) must still
290
+ // steer this instance's admissions.
291
+ await this.#tierRequests.load();
292
+ await this.#capacityStore.load();
293
+ return this.#settings.load();
294
+ };
295
+ this.#ready = loadState();
296
+ }
297
+ /** Settings as of the latest load/patch (awaits the constructor load). */
298
+ async #ensureSettings() {
299
+ await this.#ready;
300
+ return this.#settings.get();
301
+ }
302
+ /**
303
+ * The session's effective operating mode (ADP-054): its per-session
304
+ * override when one is set, else the global mode. Every session-scoped
305
+ * supervision decision consults this instead of `settings.mode` directly.
306
+ */
307
+ #modeFor(sessionID, settings) {
308
+ return this.#sessionModes.get(sessionID) ?? settings.mode;
309
+ }
310
+ // ---------- public entry points (never throw — fail-open, ADP-031) ----------
311
+ /** Handle one host report (event pump or session hook). */
312
+ async handleReport(report) {
313
+ try {
314
+ const settings = await this.#ensureSettings();
315
+ // An OFF session is fully inert — no supervision, no receipts, no
316
+ // health evidence. Idle is the exception: it is the bookkeeping
317
+ // boundary (parked-work sweep, dead-session override pruning) and runs
318
+ // for every report so other sessions' overrides are maintained; the
319
+ // sweep itself gates per parked session below.
320
+ if (report.kind !== "idle" && this.#modeFor(report.sessionID, settings) === "off")
321
+ return;
322
+ await this.#dispatch(report, settings);
323
+ }
324
+ catch (error) {
325
+ logFailOpen("report", error);
326
+ }
327
+ }
328
+ /** Marks the session's manual-selection state from an external selection event. */
329
+ #noteManualSelection(sessionID) {
330
+ if (!this.#manualSelections.has(sessionID) &&
331
+ this.#manualSelections.size >= _a.MAX_MANUAL_SELECTIONS) {
332
+ // The guard above ensures the map is at its cap (>= 1 entries), so the
333
+ // first key always exists; the assertion avoids a branch the coverage
334
+ // gate would reject as unreachable.
335
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
336
+ const oldest = this.#manualSelections.keys().next().value;
337
+ this.#manualSelections.delete(oldest);
338
+ }
339
+ this.#manualSelections.set(sessionID, true);
340
+ }
341
+ /**
342
+ * True while the session's last model change was the user's own pick.
343
+ * A standing settings pin is explicit manual intent as well (ADP-002):
344
+ * honoring it does not fight the pick, so suppression is skipped there.
345
+ */
346
+ #manuallySelected(sessionID, settings) {
347
+ return settings.pin === null && this.#manualSelections.has(sessionID);
348
+ }
349
+ /**
350
+ * Handle one `retry` hook invocation. On a veto this mutates
351
+ * `input.decision` to `{ retry: false }`; otherwise the decision is left
352
+ * untouched and OpenCode's native retry policy stands.
353
+ */
354
+ async handleRetry(input) {
355
+ try {
356
+ const settings = await this.#ensureSettings();
357
+ if (this.#modeFor(input.sessionID, settings) === "off")
358
+ return;
359
+ await this.#handleRetry(input, settings);
360
+ }
361
+ catch (error) {
362
+ logFailOpen("retry", error);
363
+ }
364
+ }
365
+ /**
366
+ * Record an escalation-tool request (ADP-015 agent self-report) against the
367
+ * session's current work unit. Returns false when no unit is active — the
368
+ * request is then discarded (escalation is a per-unit signal).
369
+ */
370
+ noteEscalationRequest(sessionID, reason, evidence) {
371
+ const unit = this.#tracker.active(sessionID);
372
+ if (unit === undefined)
373
+ return false;
374
+ const recorded = this.#escalationEvidence.get(unit.id) ?? [];
375
+ recorded.push({
376
+ kind: "agent-request",
377
+ detail: escalationDetail({ reason, evidence }),
378
+ });
379
+ this.#escalationEvidence.set(unit.id, recorded.slice(-MAX_ESCALATION_EVIDENCE));
380
+ return true;
381
+ }
382
+ /**
383
+ * Record an agent-declared tier request for the session (the adaptive_tier
384
+ * tool). STICKY: it steers the session's next and following admissions
385
+ * until cleared — an agent works across many units in one task. Returns
386
+ * false on an unknown tier (the tool re-validates before calling).
387
+ *
388
+ * Fire-and-forget persistence: the recorder signature is sync on both the
389
+ * tool and the RPC source interfaces, so the write is enqueued without
390
+ * awaiting it. Readers (routing, getTierRequest, the idle sweep) flush the
391
+ * store's queue before reading, which makes the record visible
392
+ * deterministically; a write failure drops the record fail-open (the tool
393
+ * told the agent "recorded", the persisted mirror simply never sees it —
394
+ * the same degradation the in-memory map had on restart).
395
+ */
396
+ noteTierRequest(sessionID, tier) {
397
+ if (!isTierName(tier))
398
+ return false;
399
+ void this.#tierRequests.set(sessionID, tier).catch(() => undefined);
400
+ // The user/agent explicitly asked Adaptive to route again (ADP-028).
401
+ this.#manualSelections.delete(sessionID);
402
+ return true;
403
+ }
404
+ /** The session's tier request, or `null` when it follows the routing ladder. */
405
+ async getTierRequest(sessionID) {
406
+ await this.#ready;
407
+ // A fire-and-forget note may still be in the queue: flush before reading.
408
+ await this.#tierRequests.flush();
409
+ return this.#tierRequests.get(sessionID);
410
+ }
411
+ /** Clear one session's tier request (`null` is the only valid input). */
412
+ async setTierRequest(sessionID, tier) {
413
+ await this.#ready;
414
+ if (tier !== null)
415
+ throw new Error("only null clears a tier request");
416
+ await this.#tierRequests.set(sessionID, null);
417
+ // Clearing through the control surface is the user asking Adaptive back.
418
+ this.#manualSelections.delete(sessionID);
419
+ }
420
+ // ---------- state API for the control surfaces (ADP-033/034/036) ----------
421
+ /** Current settings (awaits the constructor load). */
422
+ async settings() {
423
+ return this.#ensureSettings();
424
+ }
425
+ /** Validate and persist a settings patch; returns per-field errors. */
426
+ async updateSettings(patch) {
427
+ await this.#ready;
428
+ const result = await this.#settings.update(patch);
429
+ if (result.ok && patch.mode !== undefined) {
430
+ // A global mode change is the user steering Adaptive explicitly
431
+ // (ADP-028): manual-selection suppression lifts for every session.
432
+ this.#manualSelections.clear();
433
+ }
434
+ return result;
435
+ }
436
+ /** The session's mode override, or `null` when it follows the global mode. */
437
+ async getSessionMode(sessionID) {
438
+ await this.#ready;
439
+ return this.#sessionModes.get(sessionID);
440
+ }
441
+ /**
442
+ * Persist one session's mode override (`null` clears it). Throws on write
443
+ * failure — the RPC handler degrades that to an error patch result
444
+ * (fail-open, ADP-031), while the routing path never calls this.
445
+ */
446
+ async setSessionMode(sessionID, mode) {
447
+ await this.#ready;
448
+ await this.#sessionModes.set(sessionID, mode);
449
+ // A mode change is the user steering Adaptive explicitly (ADP-028).
450
+ this.#manualSelections.delete(sessionID);
451
+ }
452
+ /**
453
+ * Bounded status snapshot for the status view (ADP-033). Health is read
454
+ * from the persisted record set (not the in-memory registry): the serving
455
+ * instance's registry can be empty when the host's plugin reconciliation
456
+ * placed the event-pump hooks on another instance, while the persisted
457
+ * mirror is shared by every instance.
458
+ */
459
+ async status() {
460
+ const settings = await this.#ensureSettings();
461
+ await this.#healthRecords.load();
462
+ // The persisted mirror serves the status surface: a serving instance
463
+ // without the event-pump hooks must still report sensed capacity.
464
+ await this.#capacityStore.load();
465
+ const now = this.#options.clock();
466
+ const records = this.#healthRecords.snapshot().map((record) => sweepRecord(record, now));
467
+ // Capacity readings are gathered per configured ref (mirroring the health
468
+ // input shape) so buildStatus stays a pure function of its inputs.
469
+ const capacity = {};
470
+ for (const tier of TIERS) {
471
+ for (const ref of settings.tiers[tier]) {
472
+ const chainIDs = new Set(resourceChain(refKey(ref)).map(resourceKeyID));
473
+ const candidates = this.#capacityStore
474
+ .snapshot()
475
+ .filter((entry) => chainIDs.has(resourceKeyID(entry.key)))
476
+ .map((entry) => entry.reading);
477
+ capacity[schedulerHealthKey(ref)] = mergeReadings(candidates, now);
478
+ }
479
+ }
480
+ return buildStatus({
481
+ settings,
482
+ health: records,
483
+ capacity,
484
+ parked: this.#parked.count(),
485
+ receipts: this.#receipts.snapshot().length,
486
+ });
487
+ }
488
+ /** Newest-first bounded receipt view (ADP-030). */
489
+ async receipts() {
490
+ await this.#ready;
491
+ return this.#receipts.snapshot().slice(-RECEIPTS_VIEW_LIMIT).reverse();
492
+ }
493
+ // ---------- dispatch ----------
494
+ async #dispatch(report, settings) {
495
+ switch (report.kind) {
496
+ case "execution-started":
497
+ await this.#onExecutionStarted(report, settings);
498
+ return;
499
+ case "execution-succeeded":
500
+ await this.#onExecutionSucceeded(report);
501
+ return;
502
+ case "execution-failed":
503
+ await this.#onExecutionFailed(report, settings);
504
+ return;
505
+ case "execution-interrupted":
506
+ await this.#onExecutionInterrupted(report);
507
+ return;
508
+ case "model-selected": {
509
+ const result = this.#tracker.apply({
510
+ kind: "model-selected",
511
+ sessionID: report.sessionID,
512
+ at: report.at,
513
+ model: report.model,
514
+ });
515
+ // Whether the change was the user's pick or Adaptive's own switch
516
+ // decides who controls the session's model (ADP-028); the result
517
+ // carries the attribution the tracker resolved. Only the two
518
+ // selection sources reach this event kind.
519
+ const { source } = result.transition;
520
+ if (source === "selected-adaptive")
521
+ this.#manualSelections.delete(report.sessionID);
522
+ else
523
+ this.#noteManualSelection(report.sessionID);
524
+ return;
525
+ }
526
+ case "model-request":
527
+ if (report.agent !== undefined) {
528
+ if (!this.#agents.has(report.sessionID) &&
529
+ this.#agents.size >= _a.MAX_AGENT_HINTS) {
530
+ // The guard above ensures the map is at its cap (>= 1 entries),
531
+ // so the first key always exists; the assertion avoids a branch
532
+ // the coverage gate would reject as unreachable.
533
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
534
+ const oldest = this.#agents.keys().next().value;
535
+ this.#agents.delete(oldest);
536
+ }
537
+ this.#agents.set(report.sessionID, report.agent);
538
+ }
539
+ // Only the agent loop's own request reports the model actually
540
+ // executing the unit's work. An auxiliary request (title, compaction,
541
+ // generate — ADP-027) carries the host's own cheap pick for that side
542
+ // request; adopting it as the session's model would misattribute the
543
+ // primary unit's health evidence and leave the next admission
544
+ // believing the unit runs on the wrong resource.
545
+ if (report.requestKind === "primary") {
546
+ this.#tracker.apply({
547
+ kind: "model-request",
548
+ sessionID: report.sessionID,
549
+ at: this.#options.clock(),
550
+ model: report.model,
551
+ });
552
+ }
553
+ return;
554
+ case "http-response":
555
+ await this.#onHttpResponse(report);
556
+ return;
557
+ case "idle":
558
+ await this.#onIdle(settings);
559
+ return;
560
+ case "prompt":
561
+ await this.#onPrompt(report.sessionID, settings);
562
+ return;
563
+ }
564
+ }
565
+ // ---------- admission ----------
566
+ /**
567
+ * CONTROL-mode admission in the awaited prompt hook. The hook runs before
568
+ * the turn's model resolution, so the applied model governs the very turn
569
+ * that triggered admission (the event-pump execution-started report is
570
+ * asynchronous and would only affect the next turn — verified on host
571
+ * 2.2.5). OBSERVE never switches, so it needs no prompt-time decision and
572
+ * stays on the pump entirely.
573
+ */
574
+ async #onPrompt(sessionID, settings) {
575
+ const mode = this.#modeFor(sessionID, settings);
576
+ if (mode !== "active")
577
+ return;
578
+ if (this.#continuationPending.delete(sessionID)) {
579
+ // An owned continuation (retry recovery, escalation, parked resume)
580
+ // already owns this unit's routing: re-arm the admission marker from
581
+ // the continuation's tier intent so execution-started skips a fresh
582
+ // (continuation-unaware) admission.
583
+ const tier = this.#continuationTiers.get(sessionID);
584
+ this.#promptAdmission.set(sessionID, tier !== undefined ? { tier } : {});
585
+ return;
586
+ }
587
+ // A fresh user prompt cancels any stale continuation intent and marker:
588
+ // this admission re-routes from scratch.
589
+ this.#continuationTiers.delete(sessionID);
590
+ this.#promptAdmission.delete(sessionID);
591
+ this.#escalations.delete(sessionID);
592
+ // A pending probe ticket from an earlier prompt admission (or a failed
593
+ // recovery) can only be carried by the unit the ORIGINAL prompt opens;
594
+ // a fresh admission means that episode never ran, so the ticket is
595
+ // released here instead of being adopted by whichever unit opens next.
596
+ const stale = this.#sessionProbeTickets.get(sessionID);
597
+ if (stale !== undefined) {
598
+ this.#sessionProbeTickets.delete(sessionID);
599
+ this.#health.releaseProbe(stale);
600
+ }
601
+ if (this.#manuallySelected(sessionID, settings)) {
602
+ // ADP-028: the user's own model pick stands. Recording the defer (no
603
+ // switch, no marker) leaves OpenCode's choice — the picked model — in
604
+ // place; execution-started will skip routing the same way.
605
+ await this.#recordReceipt({
606
+ sessionID,
607
+ decision: { kind: "defer", reason: "routing-defer", detail: "manual-selection" },
608
+ });
609
+ return;
610
+ }
611
+ let probeTicket;
612
+ try {
613
+ const admission = await this.#admit(settings, sessionID);
614
+ probeTicket = admission.probeTicket;
615
+ const wrapped = schedulerForMode(admission.decision, mode);
616
+ if (wrapped.kind === "apply") {
617
+ await this.#ops.switchModel(sessionID, wrapped.model);
618
+ }
619
+ // A pin is not a tier — receipts only carry a tier for tiered routing.
620
+ const tier = routingTier(admission.routing);
621
+ await this.#recordReceipt({
622
+ sessionID,
623
+ ...(tier !== undefined ? { tier } : {}),
624
+ decision: wrapped,
625
+ });
626
+ this.#promptAdmission.set(sessionID, { ...(tier !== undefined ? { tier } : {}) });
627
+ // The prompt-admitted probe episode is the turn that follows.
628
+ if (wrapped.kind === "apply" && probeTicket !== undefined) {
629
+ this.#sessionProbeTickets.set(sessionID, probeTicket);
630
+ }
631
+ }
632
+ catch (error) {
633
+ // A probe admitted for this turn is lost when the admission fails (the
634
+ // switch threw): no unit will ever carry the ticket, so release it.
635
+ if (probeTicket !== undefined) {
636
+ this.#health.releaseProbe(probeTicket);
637
+ }
638
+ // Fail-open: no marker → execution-started re-routes the unit itself.
639
+ logFailOpen("admission", error);
640
+ await this.#recordReceipt({
641
+ sessionID,
642
+ decision: { kind: "defer", reason: "routing-defer", detail: "internal error" },
643
+ });
644
+ }
645
+ }
646
+ async #onExecutionStarted(report, settings) {
647
+ const opened = this.#tracker.apply({
648
+ kind: "execution-started",
649
+ sessionID: report.sessionID,
650
+ at: report.at,
651
+ });
652
+ const unit = opened.unit;
653
+ // The prompt-hook marker belongs to the unit this report OPENS, so read
654
+ // it before the close cleanup: a superseding start would otherwise
655
+ // discard it and re-route an already-admitted turn.
656
+ const admitted = this.#promptAdmission.get(report.sessionID);
657
+ // A superseding start closes the previous episode — release its
658
+ // per-unit bookkeeping (veto budget) too.
659
+ this.#afterUnitClose(opened);
660
+ // The prompt hook already admitted and applied for this unit — carry the
661
+ // tier into escalation context and skip re-routing.
662
+ if (admitted !== undefined) {
663
+ this.#promptAdmission.delete(report.sessionID);
664
+ this.#continuationTiers.delete(report.sessionID);
665
+ if (admitted.tier !== undefined)
666
+ this.#admissionTiers.set(unit.id, admitted.tier);
667
+ // The prompt-time admission receipt was recorded before this unit
668
+ // existed; adopt it so outcomes attach to it like any unit receipt.
669
+ try {
670
+ await this.#receipts.adoptUnit(report.sessionID, unit.id);
671
+ }
672
+ catch (error) {
673
+ logFailOpen("receipts", error);
674
+ }
675
+ const sessionTicket = this.#sessionProbeTickets.get(report.sessionID);
676
+ if (sessionTicket !== undefined) {
677
+ this.#sessionProbeTickets.delete(report.sessionID);
678
+ this.#probeTickets.set(unit.id, sessionTicket);
679
+ }
680
+ return;
681
+ }
682
+ // A probe admitted while computing a veto's recovery belongs to the
683
+ // continuation episode that follows — the first start on the session
684
+ // after the veto.
685
+ const carriedTicket = this.#sessionProbeTickets.get(report.sessionID);
686
+ if (carriedTicket !== undefined) {
687
+ this.#sessionProbeTickets.delete(report.sessionID);
688
+ this.#probeTickets.set(unit.id, carriedTicket);
689
+ }
690
+ try {
691
+ await this.#routeAdmission(unit, settings, this.#modeFor(unit.sessionID, settings));
692
+ }
693
+ catch (error) {
694
+ logFailOpen("admission", error);
695
+ await this.#recordReceipt({
696
+ sessionID: unit.sessionID,
697
+ unitID: unit.id,
698
+ decision: { kind: "defer", reason: "routing-defer", detail: "internal error" },
699
+ });
700
+ }
701
+ }
702
+ /** Fresh admission: routing ladder → scheduler, raw (unwrapped). */
703
+ async #admit(settings, sessionID, requiredTier) {
704
+ // The agent-declared tier request (adaptive_tier tool) outranks the
705
+ // role hints but stays below the pin (ADP-002 ladder). The note is
706
+ // recorded fire-and-forget, so flush the store's queue before reading.
707
+ await this.#tierRequests.flush();
708
+ const tierRequest = this.#tierRequests.get(sessionID);
709
+ let routedTier;
710
+ if (requiredTier !== undefined) {
711
+ routedTier = { tier: requiredTier, via: "manual-command" };
712
+ }
713
+ else if (tierRequest !== null) {
714
+ routedTier = { tier: tierRequest, via: "manual-command" };
715
+ }
716
+ const routing = resolveRouting({
717
+ ...(settings.pin !== null ? { pin: settings.pin } : {}),
718
+ ...(routedTier !== undefined ? { tierRequest: routedTier } : {}),
719
+ // The agent-role hint is the session's last observed agent — the host
720
+ // reports it on every model request (ADP-002 level 3).
721
+ ...(this.#agents.has(sessionID) ? { agent: this.#agents.get(sessionID) } : {}),
722
+ agentTiers: settings.agentTiers,
723
+ configuredTiers: configuredTiers(settings),
724
+ });
725
+ const tierCandidates = routing.kind === "tier" ? settings.tiers[routing.tier] : [];
726
+ const inventory = await listCandidates(this.#options.model);
727
+ // Capacity sensors are pull-based: refresh before assessing so the
728
+ // admission decision sees current readings.
729
+ await this.#capacity.refresh();
730
+ const health = {};
731
+ const capacity = {};
732
+ // Tickets issued by this assessment sweep (see the release below).
733
+ const issued = [];
734
+ // Only the routed candidates are assessed: assess flips a due breaker to
735
+ // half_open, and admitting a quick unit has no business touching an
736
+ // advanced-tier resource's breaker.
737
+ const routed = routing.kind === "pin" ? [routing.model] : tierCandidates;
738
+ for (const ref of routed) {
739
+ const mapKey = schedulerHealthKey(ref);
740
+ const assessment = this.#health.assess(refKey(ref));
741
+ health[mapKey] = assessment;
742
+ if (assessment.kind === "probe")
743
+ issued.push(assessment.ticket);
744
+ const capacityAssessment = this.#capacity.assessment(refKey(ref));
745
+ if (capacityAssessment.kind === "estimate")
746
+ capacity[mapKey] = capacityAssessment.signals;
747
+ }
748
+ const input = {
749
+ routing,
750
+ // The host exposes no per-episode demand source in the v2 wiring
751
+ // (no tool/modality/context requirement reaches the plugin), so the
752
+ // compatibility gate runs on availability metadata only. Documented
753
+ // limitation — see TEST_STRATEGY "Compatibility gate demand".
754
+ demand: {},
755
+ inventory,
756
+ tierCandidates,
757
+ health,
758
+ capacity,
759
+ };
760
+ const decision = admit(input);
761
+ // Assess admits a half-open probe for every due breaker it touches —
762
+ // including candidates this decision passed over (an earlier ready
763
+ // candidate wins) or dispatched nothing for (defer/park). An
764
+ // issued-but-undispatched probe would wedge its resource in half_open:
765
+ // no unit ever carries the ticket. Release each unselected ticket — it
766
+ // stays due, because a probe that never ran is no health evidence and
767
+ // restarting its backoff would push it back on every admission.
768
+ const selectedId = decision.kind === "apply" ? decision.evidence.probeId : undefined;
769
+ let probeTicket;
770
+ for (const ticket of issued) {
771
+ if (selectedId !== undefined && ticket.id === selectedId) {
772
+ probeTicket = ticket;
773
+ }
774
+ else {
775
+ this.#health.releaseProbe(ticket);
776
+ }
777
+ }
778
+ return { routing, decision, ...(probeTicket !== undefined ? { probeTicket } : {}) };
779
+ }
780
+ async #routeAdmission(unit, settings, mode) {
781
+ if (this.#manuallySelected(unit.sessionID, settings)) {
782
+ // ADP-028: the session runs on the user's own pick — no re-route.
783
+ await this.#recordReceipt({
784
+ sessionID: unit.sessionID,
785
+ unitID: unit.id,
786
+ decision: { kind: "defer", reason: "routing-defer", detail: "manual-selection" },
787
+ });
788
+ return;
789
+ }
790
+ const { routing, decision, probeTicket } = await this.#admit(settings, unit.sessionID);
791
+ // The tier the unit was admitted under is escalation context (ADP-015);
792
+ // recorded for every tiered routing outcome (including a defer, which
793
+ // still names the tier it could not honor).
794
+ if (routing.kind !== "pin") {
795
+ this.#admissionTiers.set(unit.id, routing.tier);
796
+ }
797
+ const wrapped = schedulerForMode(decision, mode);
798
+ if (wrapped.kind === "apply") {
799
+ try {
800
+ await this.#applyModel(unit, wrapped);
801
+ }
802
+ catch (error) {
803
+ // The episode cannot carry the probe when the switch itself failed —
804
+ // release the ticket instead of wedging the resource half-open.
805
+ if (probeTicket !== undefined) {
806
+ this.#health.releaseProbe(probeTicket);
807
+ }
808
+ throw error;
809
+ }
810
+ if (probeTicket !== undefined)
811
+ this.#probeTickets.set(unit.id, probeTicket);
812
+ return;
813
+ }
814
+ if (probeTicket !== undefined) {
815
+ // OBSERVE wraps an apply — including a probe admission — without
816
+ // running it: no episode will ever report the ticket, so release it.
817
+ this.#health.releaseProbe(probeTicket);
818
+ }
819
+ // Observe-only records the identical decision; defer and park leave the
820
+ // episode on OpenCode's own model (admission cannot hold an episode).
821
+ const tier = routingTier(routing);
822
+ await this.#recordReceipt({
823
+ sessionID: unit.sessionID,
824
+ unitID: unit.id,
825
+ ...(tier !== undefined ? { tier } : {}),
826
+ decision: wrapped,
827
+ });
828
+ }
829
+ /** CONTROL-mode application at the admission boundary; OBSERVE never reaches here. */
830
+ async #applyModel(unit, decision) {
831
+ await this.#ops.switchModel(unit.sessionID, decision.model);
832
+ await this.#recordReceipt({
833
+ sessionID: unit.sessionID,
834
+ unitID: unit.id,
835
+ ...(decision.tier !== undefined ? { tier: decision.tier } : {}),
836
+ decision,
837
+ });
838
+ }
839
+ // ---------- unit termination ----------
840
+ async #onExecutionSucceeded(report) {
841
+ const unit = this.#tracker.active(report.sessionID);
842
+ const model = unit?.model ?? this.#tracker.lastModel(report.sessionID)?.model;
843
+ const probeTicket = this.#takeProbeTicket(unit?.id);
844
+ const closed = this.#tracker.apply({
845
+ kind: "execution-succeeded",
846
+ sessionID: report.sessionID,
847
+ at: report.at,
848
+ });
849
+ this.#afterUnitClose(closed);
850
+ if (model !== undefined) {
851
+ this.#observeHealth({ kind: "success", key: refKey(model) });
852
+ }
853
+ if (unit !== undefined) {
854
+ if (probeTicket !== undefined) {
855
+ this.#reportProbeOutcome(probeTicket, { success: true });
856
+ }
857
+ await this.#attachOutcome(unit, { status: "succeeded" });
858
+ }
859
+ }
860
+ async #onExecutionFailed(report, settings) {
861
+ const unit = this.#tracker.active(report.sessionID);
862
+ const model = unit?.model ?? this.#tracker.lastModel(report.sessionID)?.model;
863
+ const unitID = unit?.id;
864
+ // The tier is read before the close clears the per-unit maps.
865
+ const admissionTier = unitID !== undefined ? this.#admissionTiers.get(unitID) : undefined;
866
+ const escalationUsed = this.#escalations.get(report.sessionID) ?? 0;
867
+ // Tool evidence is captured pre-close like the other per-unit maps.
868
+ const toolEvidence = unitID !== undefined ? (this.#escalationEvidence.get(unitID) ?? []) : [];
869
+ const probeTicket = this.#takeProbeTicket(unitID);
870
+ // The terminal classifies and dedups BEFORE the close: the close clears
871
+ // the unit's attempt-failure entry, and the terminal must consult it —
872
+ // it repeats the episode's last attempt, which an attempt signal
873
+ // (http-response or retry hook) already counted.
874
+ const classification = classifyFailure({
875
+ structured: {
876
+ type: report.error.type,
877
+ ...(report.error.retryAfterMs !== undefined
878
+ ? { retryAfterMs: report.error.retryAfterMs }
879
+ : {}),
880
+ },
881
+ status: report.error.status,
882
+ // Terminal classification may use the bounded textual last resort
883
+ // (content-policy rejections arrive as free text, not error types).
884
+ // The retry hook deliberately omits text: it must stay conservative.
885
+ text: report.error.message,
886
+ });
887
+ const countTerminal = this.#countTerminalFailure(unitID, classification.category);
888
+ const closed = this.#tracker.apply({
889
+ kind: "execution-failed",
890
+ sessionID: report.sessionID,
891
+ at: report.at,
892
+ error: report.error,
893
+ });
894
+ this.#afterUnitClose(closed);
895
+ if (model !== undefined && countTerminal) {
896
+ this.#observeHealth(failureEvent(refKey(model), classification));
897
+ }
898
+ if (unit !== undefined) {
899
+ await this.#attachOutcome(unit, { status: "failed", category: classification.category });
900
+ if (probeTicket !== undefined) {
901
+ this.#reportProbeOutcome(probeTicket, { success: false, category: classification.category });
902
+ }
903
+ }
904
+ await this.#escalateOnFailure(report, unitID, model, classification, admissionTier, escalationUsed, toolEvidence, settings);
905
+ }
906
+ /**
907
+ * Escalation targets must be usable: the scheduler contract hands
908
+ * planEscalation "already gated" resources, so the wiring filters the next
909
+ * tier's configured refs to candidates that exist in the inventory, pass
910
+ * the compatibility gate, are not health-blocked, and differ from the
911
+ * model that just failed. Evaluated WITHOUT probe admission (evaluate,
912
+ * not assess) — gating an escalation must never flip a breaker or strand
913
+ * an unowned ticket.
914
+ */
915
+ async #eligibleEscalationTargets(refs, failed) {
916
+ let inventory;
917
+ try {
918
+ inventory = await listCandidates(this.#options.model);
919
+ }
920
+ catch (error) {
921
+ // No inventory means no verifiable target: escalating would switch
922
+ // blind, so the budget is kept instead of burned.
923
+ logFailOpen("escalation", error);
924
+ return [];
925
+ }
926
+ const eligible = [];
927
+ // Compared by resource key: the host reports base-model calls as variant
928
+ // "default", which is the same resource as the bare tier entry.
929
+ const failedID = resourceKeyID(refKey(failed));
930
+ for (const ref of refs) {
931
+ if (resourceKeyID(refKey(ref)) === failedID)
932
+ continue;
933
+ const candidate = inventory.find((entry) => sameModelRef(candidateRef(entry), ref));
934
+ if (candidate === undefined)
935
+ continue;
936
+ if (!checkCompatibility(candidate, {}).compatible)
937
+ continue;
938
+ if (this.#health.evaluate(refKey(ref)).kind !== "available")
939
+ continue;
940
+ eligible.push(ref);
941
+ }
942
+ return eligible;
943
+ }
944
+ /**
945
+ * Escalation on a terminally failed unit (ADP-015..017): structured
946
+ * capability evidence only, same-model variant first then next tier,
947
+ * finite budget. Control continues the interrupted work on the escalated
948
+ * resource; Observe records the identical decision; defer receipts stand.
949
+ */
950
+ async #escalateOnFailure(report, unitID, model, classification, admissionTier, escalationUsed, toolEvidence, settings) {
951
+ if (unitID === undefined ||
952
+ model === undefined ||
953
+ admissionTier === undefined ||
954
+ !CAPABILITY_EVIDENCE_CATEGORIES.includes(classification.category)) {
955
+ return;
956
+ }
957
+ const next = nextTier(admissionTier);
958
+ const evidence = [
959
+ ...toolEvidence,
960
+ { kind: "repeated-failure", detail: classification.category },
961
+ ];
962
+ const nextResources = next !== undefined ? settings.tiers[next] : [];
963
+ const eligible = await this.#eligibleEscalationTargets(nextResources, model);
964
+ const plan = planEscalation({
965
+ currentModel: model,
966
+ currentTier: admissionTier,
967
+ nextTierResources: eligible,
968
+ evidence,
969
+ budget: { used: escalationUsed, max: MAX_ESCALATIONS_PER_TASK },
970
+ });
971
+ const wrapped = schedulerForMode(plan, this.#modeFor(report.sessionID, settings));
972
+ if (wrapped.kind === "escalate") {
973
+ this.#escalations.set(report.sessionID, escalationUsed + 1);
974
+ // Recorded unit-less on purpose: the receipt describes the CONTINUATION
975
+ // decision, so the continuation unit adopts it and its outcome attaches
976
+ // here (the failed unit's own outcome attaches to its apply receipt).
977
+ await this.#recordReceipt({
978
+ sessionID: report.sessionID,
979
+ tier: wrapped.tier,
980
+ decision: wrapped,
981
+ });
982
+ // Continue the interrupted work on the escalated resource; the unit is
983
+ // terminally failed, so an ops failure is only logged (never parked).
984
+ try {
985
+ await this.#ownRecovery(report.sessionID, wrapped.target, wrapped.tier, () => this.#modeFor(report.sessionID, settings) !== "off");
986
+ }
987
+ catch (error) {
988
+ if (!(error instanceof RecoveryAbortedError))
989
+ logFailOpen("escalation-ops", error);
990
+ }
991
+ return;
992
+ }
993
+ await this.#recordReceipt({
994
+ sessionID: report.sessionID,
995
+ tier: plan.kind === "escalate" ? plan.tier : admissionTier,
996
+ decision: wrapped,
997
+ });
998
+ }
999
+ async #onExecutionInterrupted(report) {
1000
+ const reason = interruptReason(report.reason);
1001
+ if (reason === undefined) {
1002
+ logFailOpen("interrupt", new Error(`unknown interruption reason: ${report.reason.slice(0, 32)}`));
1003
+ return;
1004
+ }
1005
+ const unit = this.#tracker.active(report.sessionID);
1006
+ const probeTicket = this.#takeProbeTicket(unit?.id);
1007
+ // An interruption while a recovery is pending on this session aborts it
1008
+ // at its post-settle guard (the pending recovery's decision predates the
1009
+ // interruption and must not act after it).
1010
+ if (this.#recoveryPending.has(report.sessionID)) {
1011
+ this.#recoveryInterrupted.add(report.sessionID);
1012
+ }
1013
+ const closed = this.#tracker.apply({
1014
+ kind: "execution-interrupted",
1015
+ sessionID: report.sessionID,
1016
+ at: report.at,
1017
+ reason,
1018
+ });
1019
+ this.#afterUnitClose(closed);
1020
+ // User and host interruptions never produce health evidence (the
1021
+ // classifier maps them outside the infrastructure vocabulary).
1022
+ if (unit !== undefined) {
1023
+ // An interrupted probe is no evidence about resource health: re-arm the
1024
+ // breaker's same window rather than doubling it.
1025
+ if (probeTicket !== undefined) {
1026
+ this.#reportProbeOutcome(probeTicket, { success: false, category: "user_cancelled" });
1027
+ }
1028
+ await this.#attachOutcome(unit, { status: "interrupted", reason });
1029
+ }
1030
+ }
1031
+ /**
1032
+ * One probe ticket's resolution at its episode's termination. A probe that
1033
+ * ends without a reported outcome (superseded, lost) would wedge the
1034
+ * resource in half_open, so the close cleanup reports those as
1035
+ * interruptions — re-arming the same backoff window, never doubling it.
1036
+ */
1037
+ #reportProbeOutcome(ticket, outcome) {
1038
+ // reportProbe is total and synchronous; validation failures (stale or
1039
+ // unknown ticket) are reported by its boolean return, not by throwing.
1040
+ this.#health.reportProbe(ticket, outcome);
1041
+ }
1042
+ /**
1043
+ * One attempt's failure evidence (http-response event or retry hook):
1044
+ * returns whether THIS observation is the attempt's first count.
1045
+ */
1046
+ #countAttemptFailure(unitID, source, category) {
1047
+ if (unitID === undefined)
1048
+ return true;
1049
+ const ev = this.#attemptFailures.get(unitID) ?? {};
1050
+ if (source === "http" ? ev.retry === category : ev.http === category) {
1051
+ // The other host signal already counted this attempt: consume it.
1052
+ const closed = { ...ev, http: undefined, retry: undefined, closed: category };
1053
+ this.#attemptFailures.set(unitID, closed);
1054
+ return false;
1055
+ }
1056
+ this.#attemptFailures.set(unitID, source === "http" ? { ...ev, http: category } : { ...ev, retry: category });
1057
+ return true;
1058
+ }
1059
+ /**
1060
+ * The terminal `execution-failed` repeats the episode's last attempt: it
1061
+ * counts only when the last attempt left no live evidence (e.g. a
1062
+ * capability failure that never produced an HTTP response or retry hook).
1063
+ */
1064
+ #countTerminalFailure(unitID, category) {
1065
+ if (unitID === undefined)
1066
+ return true;
1067
+ const ev = this.#attemptFailures.get(unitID) ?? {};
1068
+ if (ev.http === category) {
1069
+ this.#attemptFailures.set(unitID, { ...ev, http: undefined });
1070
+ return false;
1071
+ }
1072
+ if (ev.retry === category) {
1073
+ this.#attemptFailures.set(unitID, { ...ev, retry: undefined });
1074
+ return false;
1075
+ }
1076
+ if (ev.closed === category) {
1077
+ this.#attemptFailures.set(unitID, { ...ev, closed: undefined });
1078
+ return false;
1079
+ }
1080
+ this.#attemptFailures.set(unitID, { ...ev, closed: category });
1081
+ return true;
1082
+ }
1083
+ /**
1084
+ * Hands a closing unit's probe ticket to its explicit outcome reporter.
1085
+ * Taken BEFORE the close: #afterUnitClose treats any ticket still held as
1086
+ * lost and re-opens the breaker, which would turn a probe's real outcome
1087
+ * into a stale report.
1088
+ */
1089
+ #takeProbeTicket(unitID) {
1090
+ if (unitID === undefined)
1091
+ return undefined;
1092
+ const ticket = this.#probeTickets.get(unitID);
1093
+ this.#probeTickets.delete(unitID);
1094
+ return ticket;
1095
+ }
1096
+ /** Bounded per-unit bookkeeping once the tracker closed the unit. */
1097
+ #afterUnitClose(closed) {
1098
+ const unitID = closedUnitID(closed);
1099
+ if (unitID !== undefined) {
1100
+ this.#vetoes.delete(unitID);
1101
+ this.#admissionTiers.delete(unitID);
1102
+ this.#escalationEvidence.delete(unitID);
1103
+ const leftover = this.#probeTickets.get(unitID);
1104
+ if (leftover !== undefined) {
1105
+ // The explicit outcome reporters take their ticket before this
1106
+ // cleanup; reaching here means the unit ended without one
1107
+ // (superseded or lost).
1108
+ this.#reportProbeOutcome(leftover, { success: false, category: "user_cancelled" });
1109
+ this.#probeTickets.delete(unitID);
1110
+ }
1111
+ }
1112
+ if (closed.kind === "opened" || closed.kind === "closed") {
1113
+ const unitID = closedUnitID(closed);
1114
+ if (unitID !== undefined) {
1115
+ this.#attemptFailures.delete(unitID);
1116
+ }
1117
+ // Per-session flags for a unit that will never open again must not
1118
+ // leak: a stale continuation mark would swallow a real user prompt's
1119
+ // admission; a stale admission marker would skip routing. The guard
1120
+ // keeps the wipe out of an in-flight continuation's mark window (its
1121
+ // own close events are pumped concurrently with the mark).
1122
+ const sid = closed.unit.sessionID;
1123
+ // A continuation tier intent pins the marker: the failed unit's close
1124
+ // event can arrive after the continuation's prompt hook set it, and
1125
+ // wiping then would re-route the continuation unit.
1126
+ if ((this.#continuationsInFlight.get(sid) ?? 0) === 0 && !this.#continuationTiers.has(sid)) {
1127
+ this.#continuationPending.delete(sid);
1128
+ this.#promptAdmission.delete(sid);
1129
+ }
1130
+ }
1131
+ }
1132
+ // ---------- http evidence ----------
1133
+ async #onHttpResponse(report) {
1134
+ if (report.capturedHeaders !== undefined) {
1135
+ this.#codex.observe(report.capturedHeaders, this.#options.clock());
1136
+ await this.#syncCapacityMirror();
1137
+ }
1138
+ if (report.status < 400)
1139
+ return;
1140
+ const unit = this.#tracker.active(report.sessionID);
1141
+ const model = unit?.model ?? this.#tracker.lastModel(report.sessionID)?.model;
1142
+ if (model === undefined)
1143
+ return;
1144
+ const classification = classifyFailure({
1145
+ status: report.status,
1146
+ ...(report.retryAfterHeader !== undefined
1147
+ ? { headers: { "retry-after": report.retryAfterHeader } }
1148
+ : {}),
1149
+ });
1150
+ // The retry hook classifies the same request for the host's retry
1151
+ // decision; counting both would double every attempt (ADP-019).
1152
+ if (this.#countAttemptFailure(unit?.id, "http", classification.category)) {
1153
+ this.#observeHealth(failureEvent(refKey(model), classification));
1154
+ }
1155
+ }
1156
+ // ---------- retry hook ----------
1157
+ async #handleRetry(input, settings) {
1158
+ const classification = classifyFailure({
1159
+ structured: {
1160
+ type: input.error.type,
1161
+ ...(input.error.retryAfterMs !== undefined
1162
+ ? { retryAfterMs: input.error.retryAfterMs }
1163
+ : {}),
1164
+ },
1165
+ status: input.error.status,
1166
+ });
1167
+ const unitID = this.#tracker.active(input.sessionID)?.id;
1168
+ // The pumped http-response event describes the same failed request;
1169
+ // counting both would double every attempt (ADP-019).
1170
+ if (this.#countAttemptFailure(unitID, "retry", classification.category)) {
1171
+ this.#observeHealth(failureEvent(refKey(input.model), classification));
1172
+ }
1173
+ // Recovery = fresh admission; the failed resource is already health-blocked.
1174
+ let recovery = { kind: "none" };
1175
+ let routingTier;
1176
+ let probeTicket;
1177
+ let admission;
1178
+ try {
1179
+ const { routing, decision, probeTicket: ticket, } = await this.#admit(settings, input.sessionID);
1180
+ routingTier = routing.kind === "pin" ? undefined : routing.tier;
1181
+ if (decision.kind === "apply") {
1182
+ admission = decision;
1183
+ recovery = {
1184
+ kind: "switch",
1185
+ model: decision.model,
1186
+ ...(decision.evidence.probeId !== undefined
1187
+ ? { probeId: decision.evidence.probeId }
1188
+ : {}),
1189
+ };
1190
+ probeTicket = ticket;
1191
+ }
1192
+ }
1193
+ catch (error) {
1194
+ logFailOpen("recovery", error);
1195
+ }
1196
+ const vetoesUsed = unitID !== undefined ? (this.#vetoes.get(unitID) ?? 0) : 0;
1197
+ const core = decideRetry({
1198
+ classification,
1199
+ failingModel: input.model,
1200
+ native: input.decision,
1201
+ recovery,
1202
+ vetoesUsed,
1203
+ });
1204
+ const wrapped = retryForMode(core, this.#modeFor(input.sessionID, settings));
1205
+ if (wrapped.kind !== "veto") {
1206
+ // OBSERVE (and a declined veto) never runs the recovery episode, so an
1207
+ // admitted probe has no continuation to report it — release it here or
1208
+ // the resource wedges half-open.
1209
+ if (probeTicket !== undefined) {
1210
+ this.#health.releaseProbe(probeTicket);
1211
+ }
1212
+ return;
1213
+ }
1214
+ input.decision = { retry: false };
1215
+ if (unitID !== undefined) {
1216
+ this.#vetoes.set(unitID, vetoesUsed + 1);
1217
+ }
1218
+ // A probe admitted by this recovery belongs to the continuation episode
1219
+ // that ownRecovery is about to start; its termination reports the probe.
1220
+ if (probeTicket !== undefined) {
1221
+ // The session holds one pending probe slot. A still-pending older
1222
+ // ticket belongs to a recovery whose continuation has not started (its
1223
+ // episode can no longer claim this slot) — release it before replacing.
1224
+ const stale = this.#sessionProbeTickets.get(input.sessionID);
1225
+ if (stale !== undefined && stale !== probeTicket) {
1226
+ this.#health.releaseProbe(stale);
1227
+ }
1228
+ this.#sessionProbeTickets.set(input.sessionID, probeTicket);
1229
+ }
1230
+ // The continuation episode is not re-admitted — its prompt hook is
1231
+ // consumed as an owned continuation — so the recovery admission is
1232
+ // recorded here, unit-less; the unit that opens next adopts this receipt
1233
+ // and carries the outcome.
1234
+ // A veto implies the admission was an apply: the recovery option exists
1235
+ // only when the fresh admission selected a resource, so the assertion
1236
+ // cannot be undefined on this path.
1237
+ await this.#recordReceipt({
1238
+ sessionID: input.sessionID,
1239
+ tier: routingTier,
1240
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a veto implies the admission was an apply (the recovery exists only then)
1241
+ decision: admission,
1242
+ });
1243
+ void this.#ownRecovery(input.sessionID, wrapped.model, routingTier, () => this.#modeFor(input.sessionID, settings) !== "off")
1244
+ .then(() => undefined)
1245
+ .catch(async (error) => {
1246
+ if (error instanceof RecoveryAbortedError) {
1247
+ // The user steered away (interruption / OFF) during the settle: the
1248
+ // veto stands, but no continuation episode will ever carry the
1249
+ // probe it admitted — release it instead of wedging half-open.
1250
+ if (probeTicket !== undefined &&
1251
+ this.#sessionProbeTickets.get(input.sessionID) === probeTicket) {
1252
+ this.#sessionProbeTickets.delete(input.sessionID);
1253
+ this.#health.releaseProbe(probeTicket);
1254
+ }
1255
+ return;
1256
+ }
1257
+ logFailOpen("recovery-ops", error);
1258
+ // The recovery rejected before (or while) delivering its continuation
1259
+ // prompt, so no unit will ever carry the probe it admitted — release
1260
+ // it instead of wedging the resource half-open.
1261
+ if (probeTicket !== undefined &&
1262
+ this.#sessionProbeTickets.get(input.sessionID) === probeTicket) {
1263
+ this.#sessionProbeTickets.delete(input.sessionID);
1264
+ this.#health.releaseProbe(probeTicket);
1265
+ }
1266
+ if (unitID === undefined || routingTier === undefined)
1267
+ return;
1268
+ try {
1269
+ await this.#parked.park({
1270
+ id: unitID,
1271
+ sessionID: input.sessionID,
1272
+ tier: routingTier,
1273
+ reason: "all-blocked",
1274
+ detail: "continuation failed",
1275
+ parkedAt: this.#options.clock(),
1276
+ });
1277
+ }
1278
+ catch (parkError) {
1279
+ logFailOpen("parked", parkError);
1280
+ }
1281
+ });
1282
+ }
1283
+ /**
1284
+ * Owned horizontal recovery: settle → switch → continue from session
1285
+ * state. `tier` is the continuation unit's routing tier — recorded so the
1286
+ * continuation's prompt hook re-arms the admission marker (the unit must
1287
+ * not be re-routed: that would discard the recovery/escalation decision).
1288
+ */
1289
+ async #ownRecovery(sessionID, model, tier, stillValid) {
1290
+ if (tier !== undefined)
1291
+ this.#continuationTiers.set(sessionID, tier);
1292
+ // The guard window opens before the first await so an interrupt pumped
1293
+ // during the admission race is still seen.
1294
+ this.#recoveryPending.add(sessionID);
1295
+ try {
1296
+ const alive = await this.#isSessionAlive(sessionID);
1297
+ const recovery = { kind: "switch", model };
1298
+ const action = planContinuation({
1299
+ recovery,
1300
+ continuationPossible: alive,
1301
+ });
1302
+ // executeContinuation settles first; the supervisor settles here so the
1303
+ // post-settle guard (mode OFF, user interruption) can abort before any
1304
+ // switch or prompt — the settle is the only step that can take
1305
+ // unbounded time, and a decision made before it may be stale after it.
1306
+ await this.#ops.settle(sessionID);
1307
+ if (!stillValid() || this.#recoveryInterrupted.delete(sessionID)) {
1308
+ throw new RecoveryAbortedError(sessionID);
1309
+ }
1310
+ // Already settled above; the action's own settle is a no-op.
1311
+ await executeContinuation({
1312
+ sessionID,
1313
+ action,
1314
+ ops: { ...this.#ops, settle: () => Promise.resolve() },
1315
+ });
1316
+ }
1317
+ catch (error) {
1318
+ // The continuation prompt never reached the host, so no unit will open
1319
+ // to consume the tier intent — drop it or it would pin the marker
1320
+ // wipes and mislabel a later user prompt's admission.
1321
+ if (tier !== undefined)
1322
+ this.#continuationTiers.delete(sessionID);
1323
+ throw error;
1324
+ }
1325
+ finally {
1326
+ this.#recoveryPending.delete(sessionID);
1327
+ }
1328
+ }
1329
+ // ---------- parked-work resume (safe boundary: idle) ----------
1330
+ async #onIdle(settings) {
1331
+ const records = this.#parked.list();
1332
+ // Override holders join the alive sweep: a dead session's override is
1333
+ // pruned here (its per-session steering is worthless once the session is
1334
+ // gone), in the same sweep that clears the other per-session maps.
1335
+ const overrideIDs = this.#sessionModes.snapshot().map((record) => record.sessionID);
1336
+ const tierRequestIDs = this.#tierRequests.snapshot().map((record) => record.sessionID);
1337
+ if (records.length === 0 && overrideIDs.length === 0 && tierRequestIDs.length === 0)
1338
+ return;
1339
+ // OFF sessions are not supervised: their parked records are neither
1340
+ // dropped nor resumed, and no alive probe runs for them.
1341
+ const supervised = new Set(records
1342
+ .map((record) => record.sessionID)
1343
+ .filter((sessionID) => this.#modeFor(sessionID, settings) !== "off"));
1344
+ const alive = new Map();
1345
+ for (const sessionID of new Set([...supervised, ...overrideIDs])) {
1346
+ alive.set(sessionID, await this.#isSessionAlive(sessionID));
1347
+ }
1348
+ // Tier-request holders join the alive sweep too: a dead session's sticky
1349
+ // tier request dies with its session, like the other per-session maps.
1350
+ for (const sessionID of tierRequestIDs) {
1351
+ if (!alive.has(sessionID))
1352
+ alive.set(sessionID, await this.#isSessionAlive(sessionID));
1353
+ }
1354
+ const plan = selectResumable(records, {
1355
+ tierConfigured: (tier) => configuredTiers(settings).includes(tier),
1356
+ sessionAlive: (sessionID) => alive.get(sessionID) === true,
1357
+ });
1358
+ for (const dropped of plan.drop) {
1359
+ if (this.#modeFor(dropped.sessionID, settings) === "off")
1360
+ continue;
1361
+ await this.#dropParked(dropped);
1362
+ }
1363
+ // Tier requests die with their session, like the other per-session maps.
1364
+ // The fire-and-forget note means the queue may still hold writes; the
1365
+ // serialized set applies after them. A write failure just leaves the
1366
+ // record (fail-open — it will die with the session on the next sweep).
1367
+ for (const sessionID of tierRequestIDs) {
1368
+ if (alive.get(sessionID) === false) {
1369
+ try {
1370
+ await this.#tierRequests.set(sessionID, null);
1371
+ }
1372
+ catch (error) {
1373
+ logFailOpen("tier-requests", error);
1374
+ }
1375
+ }
1376
+ }
1377
+ // At most ONE continuation per session per sweep: prompting a session
1378
+ // with an active unit would supersede the user's in-flight episode (not
1379
+ // a safe boundary), and several back-to-back continuation prompts could
1380
+ // redo work an earlier one already completed (ADP-023).
1381
+ const attempted = new Set();
1382
+ for (const record of plan.attempt) {
1383
+ if (this.#modeFor(record.sessionID, settings) === "off")
1384
+ continue;
1385
+ if (this.#tracker.active(record.sessionID) !== undefined)
1386
+ continue;
1387
+ if (attempted.has(record.sessionID))
1388
+ continue;
1389
+ attempted.add(record.sessionID);
1390
+ await this.#resumeAttempt(record, settings);
1391
+ }
1392
+ for (const [sessionID, isAlive] of alive) {
1393
+ if (!isAlive) {
1394
+ this.#continuationTiers.delete(sessionID);
1395
+ this.#promptAdmission.delete(sessionID);
1396
+ this.#continuationPending.delete(sessionID);
1397
+ this.#escalations.delete(sessionID);
1398
+ if (this.#sessionModes.get(sessionID) !== null) {
1399
+ try {
1400
+ await this.#sessionModes.set(sessionID, null);
1401
+ }
1402
+ catch (error) {
1403
+ logFailOpen("session-modes", error);
1404
+ }
1405
+ }
1406
+ }
1407
+ }
1408
+ }
1409
+ async #dropParked(record) {
1410
+ try {
1411
+ await this.#parked.resume(record.id);
1412
+ }
1413
+ catch (error) {
1414
+ logFailOpen("parked", error);
1415
+ }
1416
+ }
1417
+ /** One parked record's fresh routing attempt at the idle boundary. */
1418
+ async #resumeAttempt(record, settings) {
1419
+ // The parked session resumes under ITS effective mode, not the idle
1420
+ // report's session's mode.
1421
+ const mode = this.#modeFor(record.sessionID, settings);
1422
+ let decision;
1423
+ let probeTicket;
1424
+ try {
1425
+ const admission = await this.#admit(settings, record.sessionID, record.tier);
1426
+ decision = admission.decision;
1427
+ probeTicket = admission.probeTicket;
1428
+ }
1429
+ catch (error) {
1430
+ logFailOpen("resume", error);
1431
+ return;
1432
+ }
1433
+ if (mode === "observe") {
1434
+ if (probeTicket !== undefined)
1435
+ this.#health.releaseProbe(probeTicket);
1436
+ await this.#recordReceipt({
1437
+ sessionID: record.sessionID,
1438
+ unitID: record.id,
1439
+ tier: record.tier,
1440
+ decision: { kind: "observe-only", would: decision },
1441
+ });
1442
+ return;
1443
+ }
1444
+ if (decision.kind !== "apply") {
1445
+ // Still no usable resource (or the routing deferred) — stay parked. The
1446
+ // admission held no probe ticket: #admit keeps a ticket only for apply
1447
+ // decisions and releases every other one itself.
1448
+ await this.#recordReceipt({
1449
+ sessionID: record.sessionID,
1450
+ unitID: record.id,
1451
+ tier: record.tier,
1452
+ decision,
1453
+ });
1454
+ return;
1455
+ }
1456
+ const popped = await this.#popParked(record);
1457
+ if (popped === undefined) {
1458
+ if (probeTicket !== undefined)
1459
+ this.#health.releaseProbe(probeTicket);
1460
+ return;
1461
+ }
1462
+ // The resume continuation is not re-admitted (owned continuation), so
1463
+ // the freshly routed admission is recorded here, unit-less; the unit
1464
+ // that opens next adopts it and carries the outcome.
1465
+ await this.#recordReceipt({
1466
+ sessionID: record.sessionID,
1467
+ tier: record.tier,
1468
+ decision,
1469
+ });
1470
+ // The resume continuation is an owned recovery: settle first, apply the
1471
+ // freshly routed model, then prompt — the episode's first LLM call must
1472
+ // not hit the model the work was parked away from.
1473
+ try {
1474
+ if (probeTicket !== undefined)
1475
+ this.#sessionProbeTickets.set(record.sessionID, probeTicket);
1476
+ await this.#ownRecovery(record.sessionID, decision.model, record.tier,
1477
+ // The parked session's own effective mode and its still-open unit
1478
+ // window are re-checked after the settle; a session that gained an
1479
+ // active unit must not be prompted (not a safe boundary).
1480
+ () => this.#modeFor(record.sessionID, settings) !== "off" &&
1481
+ this.#tracker.active(record.sessionID) === undefined);
1482
+ }
1483
+ catch (error) {
1484
+ if (probeTicket !== undefined &&
1485
+ this.#sessionProbeTickets.get(record.sessionID) === probeTicket) {
1486
+ this.#sessionProbeTickets.delete(record.sessionID);
1487
+ this.#health.releaseProbe(probeTicket);
1488
+ }
1489
+ if (!(error instanceof RecoveryAbortedError))
1490
+ logFailOpen("resume", error);
1491
+ await this.#rePark(record);
1492
+ }
1493
+ }
1494
+ async #popParked(record) {
1495
+ try {
1496
+ return await this.#parked.resume(record.id);
1497
+ }
1498
+ catch (error) {
1499
+ logFailOpen("parked", error);
1500
+ return undefined;
1501
+ }
1502
+ }
1503
+ async #rePark(record) {
1504
+ try {
1505
+ await this.#parked.park({
1506
+ ...record,
1507
+ attempts: (record.attempts ?? 0) + 1,
1508
+ parkedAt: this.#options.clock(),
1509
+ });
1510
+ }
1511
+ catch (error) {
1512
+ logFailOpen("parked", error);
1513
+ }
1514
+ }
1515
+ // ---------- shared helpers ----------
1516
+ async #isSessionAlive(sessionID) {
1517
+ try {
1518
+ await this.#options.session.get({ sessionID });
1519
+ return true;
1520
+ }
1521
+ catch (error) {
1522
+ // The host rejects `session.get` for a missing session with a plain
1523
+ // error and exposes no typed status; only a definitive not-found
1524
+ // counts as dead. Anything else is treated as alive (fail open toward
1525
+ // keeping durable user state, ADP-031): a transient storage or host
1526
+ // error must never prune parked work, mode overrides, or tier
1527
+ // requests — the next sweep re-probes instead.
1528
+ return !/not found/i.test(String(error));
1529
+ }
1530
+ }
1531
+ async #recordReceipt(input) {
1532
+ try {
1533
+ await this.#receipts.record({ at: this.#options.clock(), ...input });
1534
+ }
1535
+ catch (error) {
1536
+ logFailOpen("receipts", error);
1537
+ }
1538
+ }
1539
+ async #attachOutcome(unit, outcome) {
1540
+ try {
1541
+ await this.#receipts.attachOutcome({ sessionID: unit.sessionID, unitID: unit.id }, outcome);
1542
+ }
1543
+ catch (error) {
1544
+ logFailOpen("receipts", error);
1545
+ }
1546
+ }
1547
+ #observeHealth(event) {
1548
+ this.#health.observe(event);
1549
+ // Persist the record mirror fire-and-forget: the status surface reads
1550
+ // persisted records so a serving instance without the event-pump hooks
1551
+ // still reports true health. Failures are fail-open (ADP-031).
1552
+ const records = this.#health.snapshot();
1553
+ void this.#healthRecords.replace(records).catch((error) => {
1554
+ logFailOpen("health", error);
1555
+ });
1556
+ }
1557
+ /**
1558
+ * Persist the capacity mirror after a passive observation (same instance
1559
+ * split rationale as the health mirror): the status surface reads persisted
1560
+ * readings so a serving instance without the event-pump hooks still reports
1561
+ * sensed capacity. Failures are fail-open (ADP-031).
1562
+ */
1563
+ async #syncCapacityMirror() {
1564
+ try {
1565
+ await this.#capacity.refresh();
1566
+ const entries = [];
1567
+ for (const sensor of this.#capacity.snapshot()) {
1568
+ if (sensor.latest !== null) {
1569
+ entries.push({ key: sensor.resource, reading: sensor.latest });
1570
+ }
1571
+ }
1572
+ await this.#capacityStore.replace(entries);
1573
+ }
1574
+ catch (error) {
1575
+ logFailOpen("capacity", error);
1576
+ }
1577
+ }
1578
+ }
1579
+ _a = Supervisor;
1580
+ /** A pin is not a tier — receipts only carry a tier for tiered routing. */
1581
+ function routingTier(routing) {
1582
+ return routing.kind === "pin" ? undefined : routing.tier;
1583
+ }
1584
+ /** The unit a tracker result closed or superseded, if any. */
1585
+ function closedUnitID(result) {
1586
+ switch (result.kind) {
1587
+ case "closed":
1588
+ return result.unit.id;
1589
+ case "opened":
1590
+ return result.superseded?.id;
1591
+ default:
1592
+ return undefined;
1593
+ }
1594
+ }
1595
+ /** ModelRef for a normalized candidate. */
1596
+ function candidateRef(candidate) {
1597
+ return {
1598
+ providerID: candidate.providerID,
1599
+ id: candidate.modelID,
1600
+ ...(candidate.variant !== undefined ? { variant: candidate.variant } : {}),
1601
+ };
1602
+ }
1603
+ /** ResourceKey for a model ref (no account level is observable in v1). */
1604
+ function refKey(ref) {
1605
+ // The host reports base-model calls with variant "default" (verified on
1606
+ // 2.2.5), while the inventory enumerates the bare model plus its named
1607
+ // variants. Taken literally, a "default" key would orphan every
1608
+ // unvarianted failure record on a key no admission ever assesses — health
1609
+ // and breaker state would be invisible to routing.
1610
+ const variant = ref.variant === "default" ? undefined : ref.variant;
1611
+ return {
1612
+ providerID: ref.providerID,
1613
+ modelID: ref.id,
1614
+ ...(variant !== undefined ? { variantID: variant } : {}),
1615
+ };
1616
+ }
1617
+ /** Health failure event from a classification (bounded structured evidence). */
1618
+ function failureEvent(key, classification) {
1619
+ return {
1620
+ kind: "failure",
1621
+ key,
1622
+ category: classification.category,
1623
+ definitive: classification.definitive,
1624
+ ...(classification.retry.retryAfterMs !== undefined
1625
+ ? { retryAfterMs: classification.retry.retryAfterMs }
1626
+ : {}),
1627
+ };
1628
+ }
1629
+ //# sourceMappingURL=supervisor.js.map