@oar-sh/oar 0.9.0

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 (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,935 @@
1
+ 'use strict';
2
+
3
+ import crypto from 'crypto';
4
+
5
+ const STATES = Object.freeze({
6
+ IDLE: 'idle',
7
+ DRAINING: 'draining',
8
+ RESTARTING: 'restarting',
9
+ AWAITING_REBIND: 'awaiting_rebind',
10
+ READY: 'ready',
11
+ });
12
+ const DEFAULT_RETRY_BACKOFF_MS = Object.freeze([1_000, 3_000, 7_000]);
13
+
14
+ function normalizeState(value) {
15
+ const state = String(value || '').trim().toLowerCase();
16
+ if (Object.values(STATES).includes(state)) return state;
17
+ return STATES.IDLE;
18
+ }
19
+
20
+ function isoNow(nowFn) {
21
+ return new Date(nowFn()).toISOString();
22
+ }
23
+
24
+ function addMs(iso, ms) {
25
+ const base = new Date(iso).getTime();
26
+ return new Date(base + ms).toISOString();
27
+ }
28
+
29
+ function parsePositiveInt(value, fallback) {
30
+ const num = Number(value);
31
+ if (!Number.isFinite(num) || num <= 0) return fallback;
32
+ return Math.round(num);
33
+ }
34
+
35
+ function parseBackoffSchedule(value, fallback = DEFAULT_RETRY_BACKOFF_MS) {
36
+ if (!Array.isArray(value)) return [...fallback];
37
+ const out = value
38
+ .map((entry) => Number(entry))
39
+ .filter((num) => Number.isFinite(num) && num > 0)
40
+ .map((num) => Math.round(num));
41
+ return out.length ? out : [...fallback];
42
+ }
43
+
44
+ function normalizeId(value) {
45
+ return String(value || '').trim() || null;
46
+ }
47
+
48
+ function parseBoolean(value, fallback = false) {
49
+ if (typeof value === 'boolean') return value;
50
+ if (value === 1 || value === '1') return true;
51
+ if (value === 0 || value === '0') return false;
52
+ if (typeof value === 'string') {
53
+ const normalized = value.trim().toLowerCase();
54
+ if (normalized === 'true') return true;
55
+ if (normalized === 'false') return false;
56
+ }
57
+ return fallback;
58
+ }
59
+
60
+ function classifyRebindConflict(code) {
61
+ const normalized = String(code || '').trim();
62
+ if (normalized === 'transaction-mismatch' || normalized === 'target-mismatch') {
63
+ return { retryable: false, terminal: true };
64
+ }
65
+ if (normalized === 'active-session-mismatch' || normalized === 'not-awaiting-rebind') {
66
+ return { retryable: true, terminal: false };
67
+ }
68
+ return { retryable: true, terminal: false };
69
+ }
70
+
71
+ export function createRelayRestartOrchestrator({
72
+ db,
73
+ gracefulTimeoutMs = 8_000,
74
+ readyCooldownMs = 1_000,
75
+ shutdownTimeoutMs = 45_000,
76
+ spawnTimeoutMs = 18_000,
77
+ rebindTimeoutMs = 20_000,
78
+ maxAttempts = 3,
79
+ retryBackoffMs = DEFAULT_RETRY_BACKOFF_MS,
80
+ now = () => Date.now(),
81
+ } = {}) {
82
+ if (!db) throw new Error('createRelayRestartOrchestrator requires db');
83
+ const safeGracefulTimeoutMs = parsePositiveInt(gracefulTimeoutMs, 8_000);
84
+ const safeReadyCooldownMs = parsePositiveInt(readyCooldownMs, 1_000);
85
+ const safeShutdownTimeoutMs = parsePositiveInt(shutdownTimeoutMs, 45_000);
86
+ const safeSpawnTimeoutMs = parsePositiveInt(spawnTimeoutMs, 18_000);
87
+ const safeRebindTimeoutMs = parsePositiveInt(rebindTimeoutMs, 20_000);
88
+ const safeMaxAttempts = parsePositiveInt(maxAttempts, 3);
89
+ const safeRetryBackoffMs = parseBackoffSchedule(retryBackoffMs);
90
+
91
+ db.exec(`
92
+ CREATE TABLE IF NOT EXISTS relay_restart_orchestrator (
93
+ id INTEGER PRIMARY KEY CHECK (id = 1),
94
+ state TEXT NOT NULL DEFAULT 'idle',
95
+ transaction_id TEXT,
96
+ target_session_id TEXT,
97
+ queued_target_session_id TEXT,
98
+ attempts INTEGER NOT NULL DEFAULT 0,
99
+ requested_count INTEGER NOT NULL DEFAULT 0,
100
+ requested_at TEXT,
101
+ draining_started_at TEXT,
102
+ restart_requested_at TEXT,
103
+ force_after_at TEXT,
104
+ force_requested_at TEXT,
105
+ spawn_deadline_at TEXT,
106
+ awaiting_rebind_at TEXT,
107
+ rebind_deadline_at TEXT,
108
+ retry_at TEXT,
109
+ retry_backoff_ms INTEGER,
110
+ retry_phase TEXT,
111
+ rebind_confirmed_at TEXT,
112
+ last_rebind_signal_at TEXT,
113
+ last_rebind_signal_session_id TEXT,
114
+ last_rebind_signal_conversation_id TEXT,
115
+ last_rebind_signal_transaction_id TEXT,
116
+ last_rebind_outcome TEXT,
117
+ last_rebind_error_code TEXT,
118
+ last_rebind_error_retryable INTEGER NOT NULL DEFAULT 0,
119
+ last_rebind_error_terminal INTEGER NOT NULL DEFAULT 0,
120
+ ready_at TEXT,
121
+ completed_at TEXT,
122
+ last_failure_phase TEXT,
123
+ last_failure_code TEXT,
124
+ last_failure_retryable INTEGER NOT NULL DEFAULT 0,
125
+ last_failure_terminal INTEGER NOT NULL DEFAULT 0,
126
+ last_failure_at TEXT,
127
+ terminal_outcome_phase TEXT,
128
+ terminal_outcome_code TEXT,
129
+ terminal_outcome_message TEXT,
130
+ terminal_outcome_attempts INTEGER NOT NULL DEFAULT 0,
131
+ terminal_outcome_at TEXT,
132
+ last_error TEXT,
133
+ updated_at TEXT NOT NULL
134
+ );
135
+ `);
136
+ const existingColumns = new Set(
137
+ db.prepare(`PRAGMA table_info(relay_restart_orchestrator)`).all().map((row) => String(row?.name || '').trim()),
138
+ );
139
+ const ensureColumn = (name, definition) => {
140
+ if (existingColumns.has(name)) return;
141
+ db.exec(`ALTER TABLE relay_restart_orchestrator ADD COLUMN ${definition}`);
142
+ existingColumns.add(name);
143
+ };
144
+ ensureColumn('rebind_confirmed_at', 'rebind_confirmed_at TEXT');
145
+ ensureColumn('last_rebind_signal_at', 'last_rebind_signal_at TEXT');
146
+ ensureColumn('last_rebind_signal_session_id', 'last_rebind_signal_session_id TEXT');
147
+ ensureColumn('last_rebind_signal_conversation_id', 'last_rebind_signal_conversation_id TEXT');
148
+ ensureColumn('last_rebind_signal_transaction_id', 'last_rebind_signal_transaction_id TEXT');
149
+ ensureColumn('last_rebind_outcome', 'last_rebind_outcome TEXT');
150
+ ensureColumn('last_rebind_error_code', 'last_rebind_error_code TEXT');
151
+ ensureColumn('last_rebind_error_retryable', 'last_rebind_error_retryable INTEGER NOT NULL DEFAULT 0');
152
+ ensureColumn('last_rebind_error_terminal', 'last_rebind_error_terminal INTEGER NOT NULL DEFAULT 0');
153
+ ensureColumn('spawn_deadline_at', 'spawn_deadline_at TEXT');
154
+ ensureColumn('rebind_deadline_at', 'rebind_deadline_at TEXT');
155
+ ensureColumn('retry_at', 'retry_at TEXT');
156
+ ensureColumn('retry_backoff_ms', 'retry_backoff_ms INTEGER');
157
+ ensureColumn('retry_phase', 'retry_phase TEXT');
158
+ ensureColumn('last_failure_phase', 'last_failure_phase TEXT');
159
+ ensureColumn('last_failure_code', 'last_failure_code TEXT');
160
+ ensureColumn('last_failure_retryable', 'last_failure_retryable INTEGER NOT NULL DEFAULT 0');
161
+ ensureColumn('last_failure_terminal', 'last_failure_terminal INTEGER NOT NULL DEFAULT 0');
162
+ ensureColumn('last_failure_at', 'last_failure_at TEXT');
163
+ ensureColumn('terminal_outcome_phase', 'terminal_outcome_phase TEXT');
164
+ ensureColumn('terminal_outcome_code', 'terminal_outcome_code TEXT');
165
+ ensureColumn('terminal_outcome_message', 'terminal_outcome_message TEXT');
166
+ ensureColumn('terminal_outcome_attempts', 'terminal_outcome_attempts INTEGER NOT NULL DEFAULT 0');
167
+ ensureColumn('terminal_outcome_at', 'terminal_outcome_at TEXT');
168
+
169
+ const ensureRow = db.prepare(`
170
+ INSERT INTO relay_restart_orchestrator (id, state, attempts, requested_count, updated_at)
171
+ VALUES (1, 'idle', 0, 0, ?)
172
+ ON CONFLICT(id) DO NOTHING
173
+ `);
174
+ const getRow = db.prepare(`SELECT * FROM relay_restart_orchestrator WHERE id = 1`);
175
+ const updateRow = db.prepare(`
176
+ UPDATE relay_restart_orchestrator
177
+ SET
178
+ state = @state,
179
+ transaction_id = @transaction_id,
180
+ target_session_id = @target_session_id,
181
+ queued_target_session_id = @queued_target_session_id,
182
+ attempts = @attempts,
183
+ requested_count = @requested_count,
184
+ requested_at = @requested_at,
185
+ draining_started_at = @draining_started_at,
186
+ restart_requested_at = @restart_requested_at,
187
+ force_after_at = @force_after_at,
188
+ force_requested_at = @force_requested_at,
189
+ spawn_deadline_at = @spawn_deadline_at,
190
+ awaiting_rebind_at = @awaiting_rebind_at,
191
+ rebind_deadline_at = @rebind_deadline_at,
192
+ retry_at = @retry_at,
193
+ retry_backoff_ms = @retry_backoff_ms,
194
+ retry_phase = @retry_phase,
195
+ rebind_confirmed_at = @rebind_confirmed_at,
196
+ last_rebind_signal_at = @last_rebind_signal_at,
197
+ last_rebind_signal_session_id = @last_rebind_signal_session_id,
198
+ last_rebind_signal_conversation_id = @last_rebind_signal_conversation_id,
199
+ last_rebind_signal_transaction_id = @last_rebind_signal_transaction_id,
200
+ last_rebind_outcome = @last_rebind_outcome,
201
+ last_rebind_error_code = @last_rebind_error_code,
202
+ last_rebind_error_retryable = @last_rebind_error_retryable,
203
+ last_rebind_error_terminal = @last_rebind_error_terminal,
204
+ ready_at = @ready_at,
205
+ completed_at = @completed_at,
206
+ last_failure_phase = @last_failure_phase,
207
+ last_failure_code = @last_failure_code,
208
+ last_failure_retryable = @last_failure_retryable,
209
+ last_failure_terminal = @last_failure_terminal,
210
+ last_failure_at = @last_failure_at,
211
+ terminal_outcome_phase = @terminal_outcome_phase,
212
+ terminal_outcome_code = @terminal_outcome_code,
213
+ terminal_outcome_message = @terminal_outcome_message,
214
+ terminal_outcome_attempts = @terminal_outcome_attempts,
215
+ terminal_outcome_at = @terminal_outcome_at,
216
+ last_error = @last_error,
217
+ updated_at = @updated_at
218
+ WHERE id = 1
219
+ `);
220
+
221
+ function readState() {
222
+ ensureRow.run(isoNow(now));
223
+ const row = getRow.get() || {};
224
+ const state = normalizeState(row.state);
225
+ const transactionId = normalizeId(row.transaction_id);
226
+ const targetSessionId = normalizeId(row.target_session_id);
227
+ const terminalOutcome = normalizeId(row.terminal_outcome_code)
228
+ ? {
229
+ phase: normalizeId(row.terminal_outcome_phase),
230
+ code: normalizeId(row.terminal_outcome_code),
231
+ message: String(row.terminal_outcome_message || '').trim() || null,
232
+ attempts: Number(row.terminal_outcome_attempts || 0),
233
+ at: row.terminal_outcome_at || null,
234
+ }
235
+ : null;
236
+ return {
237
+ state,
238
+ transactionId,
239
+ targetSessionId,
240
+ queuedTargetSessionId: normalizeId(row.queued_target_session_id),
241
+ attempts: Number(row.attempts || 0),
242
+ requestedCount: Number(row.requested_count || 0),
243
+ requestedAt: row.requested_at || null,
244
+ drainingStartedAt: row.draining_started_at || null,
245
+ restartRequestedAt: row.restart_requested_at || null,
246
+ forceAfterAt: row.force_after_at || null,
247
+ forceRequestedAt: row.force_requested_at || null,
248
+ spawnDeadlineAt: row.spawn_deadline_at || null,
249
+ awaitingRebindAt: row.awaiting_rebind_at || null,
250
+ rebindDeadlineAt: row.rebind_deadline_at || null,
251
+ retryAt: row.retry_at || null,
252
+ retryBackoffMs: Number(row.retry_backoff_ms || 0) || null,
253
+ retryPhase: normalizeId(row.retry_phase),
254
+ rebindConfirmedAt: row.rebind_confirmed_at || null,
255
+ lastRebindSignalAt: row.last_rebind_signal_at || null,
256
+ lastRebindSignalSessionId: normalizeId(row.last_rebind_signal_session_id),
257
+ lastRebindSignalConversationId: normalizeId(row.last_rebind_signal_conversation_id),
258
+ lastRebindSignalTransactionId: normalizeId(row.last_rebind_signal_transaction_id),
259
+ lastRebindOutcome: normalizeId(row.last_rebind_outcome),
260
+ lastRebindErrorCode: normalizeId(row.last_rebind_error_code),
261
+ lastRebindErrorRetryable: parseBoolean(row.last_rebind_error_retryable, false),
262
+ lastRebindErrorTerminal: parseBoolean(row.last_rebind_error_terminal, false),
263
+ readyAt: row.ready_at || null,
264
+ completedAt: row.completed_at || null,
265
+ lastFailurePhase: normalizeId(row.last_failure_phase),
266
+ lastFailureCode: normalizeId(row.last_failure_code),
267
+ lastFailureRetryable: parseBoolean(row.last_failure_retryable, false),
268
+ lastFailureTerminal: parseBoolean(row.last_failure_terminal, false),
269
+ lastFailureAt: row.last_failure_at || null,
270
+ terminalOutcomePhase: normalizeId(row.terminal_outcome_phase),
271
+ terminalOutcomeCode: normalizeId(row.terminal_outcome_code),
272
+ terminalOutcomeMessage: String(row.terminal_outcome_message || '').trim() || null,
273
+ terminalOutcomeAttempts: Number(row.terminal_outcome_attempts || 0),
274
+ terminalOutcomeAt: row.terminal_outcome_at || null,
275
+ terminalOutcome,
276
+ lastError: String(row.last_error || '').trim() || null,
277
+ gracefulTimeoutMs: safeGracefulTimeoutMs,
278
+ readyCooldownMs: safeReadyCooldownMs,
279
+ shutdownTimeoutMs: safeShutdownTimeoutMs,
280
+ spawnTimeoutMs: safeSpawnTimeoutMs,
281
+ rebindTimeoutMs: safeRebindTimeoutMs,
282
+ maxAttempts: safeMaxAttempts,
283
+ retryBackoffMsSchedule: [...safeRetryBackoffMs],
284
+ awaitingRebind: state === STATES.AWAITING_REBIND,
285
+ rebindRequired: state === STATES.AWAITING_REBIND,
286
+ expectedRebind: {
287
+ transactionId,
288
+ targetSessionId,
289
+ },
290
+ };
291
+ }
292
+
293
+ function writeState(nextState) {
294
+ const current = readState();
295
+ const merged = { ...current, ...nextState };
296
+ const row = {
297
+ state: normalizeState(merged.state),
298
+ transaction_id: merged.transactionId || null,
299
+ target_session_id: merged.targetSessionId || null,
300
+ queued_target_session_id: merged.queuedTargetSessionId || null,
301
+ attempts: Math.max(0, Number(merged.attempts || 0)),
302
+ requested_count: Math.max(0, Number(merged.requestedCount || 0)),
303
+ requested_at: merged.requestedAt || null,
304
+ draining_started_at: merged.drainingStartedAt || null,
305
+ restart_requested_at: merged.restartRequestedAt || null,
306
+ force_after_at: merged.forceAfterAt || null,
307
+ force_requested_at: merged.forceRequestedAt || null,
308
+ spawn_deadline_at: merged.spawnDeadlineAt || null,
309
+ awaiting_rebind_at: merged.awaitingRebindAt || null,
310
+ rebind_deadline_at: merged.rebindDeadlineAt || null,
311
+ retry_at: merged.retryAt || null,
312
+ retry_backoff_ms: Number(merged.retryBackoffMs || 0) || null,
313
+ retry_phase: merged.retryPhase || null,
314
+ rebind_confirmed_at: merged.rebindConfirmedAt || null,
315
+ last_rebind_signal_at: merged.lastRebindSignalAt || null,
316
+ last_rebind_signal_session_id: merged.lastRebindSignalSessionId || null,
317
+ last_rebind_signal_conversation_id: merged.lastRebindSignalConversationId || null,
318
+ last_rebind_signal_transaction_id: merged.lastRebindSignalTransactionId || null,
319
+ last_rebind_outcome: merged.lastRebindOutcome || null,
320
+ last_rebind_error_code: merged.lastRebindErrorCode || null,
321
+ last_rebind_error_retryable: merged.lastRebindErrorRetryable ? 1 : 0,
322
+ last_rebind_error_terminal: merged.lastRebindErrorTerminal ? 1 : 0,
323
+ ready_at: merged.readyAt || null,
324
+ completed_at: merged.completedAt || null,
325
+ last_failure_phase: merged.lastFailurePhase || null,
326
+ last_failure_code: merged.lastFailureCode || null,
327
+ last_failure_retryable: merged.lastFailureRetryable ? 1 : 0,
328
+ last_failure_terminal: merged.lastFailureTerminal ? 1 : 0,
329
+ last_failure_at: merged.lastFailureAt || null,
330
+ terminal_outcome_phase: merged.terminalOutcomePhase || null,
331
+ terminal_outcome_code: merged.terminalOutcomeCode || null,
332
+ terminal_outcome_message: merged.terminalOutcomeMessage || null,
333
+ terminal_outcome_attempts: Math.max(0, Number(merged.terminalOutcomeAttempts || 0)),
334
+ terminal_outcome_at: merged.terminalOutcomeAt || null,
335
+ last_error: merged.lastError || null,
336
+ updated_at: isoNow(now),
337
+ };
338
+ updateRow.run(row);
339
+ return readState();
340
+ }
341
+
342
+ function buildRestartCommand(targetSessionId) {
343
+ return `gh copilot -- --allow-all --session-id ${targetSessionId}`;
344
+ }
345
+
346
+ function buildControlPayload(state, force = false) {
347
+ if (!state?.targetSessionId || !state?.transactionId) return null;
348
+ return {
349
+ type: 'restart_cli',
350
+ transactionId: state.transactionId,
351
+ targetSessionId: state.targetSessionId,
352
+ command: buildRestartCommand(state.targetSessionId),
353
+ state: state.state,
354
+ attempts: state.attempts,
355
+ maxAttempts: safeMaxAttempts,
356
+ gracefulTimeoutMs: safeGracefulTimeoutMs,
357
+ spawnTimeoutMs: safeSpawnTimeoutMs,
358
+ rebindTimeoutMs: safeRebindTimeoutMs,
359
+ retryAt: state.retryAt || null,
360
+ retryBackoffMs: state.retryBackoffMs || null,
361
+ force,
362
+ requestedAt: state.requestedAt,
363
+ restartRequestedAt: state.restartRequestedAt,
364
+ forceAfterAt: state.forceAfterAt,
365
+ spawnDeadlineAt: state.spawnDeadlineAt || null,
366
+ rebindDeadlineAt: state.rebindDeadlineAt || null,
367
+ };
368
+ }
369
+
370
+ function startTransaction(targetSessionId, reason = null) {
371
+ const nowIso = isoNow(now);
372
+ return writeState({
373
+ state: STATES.DRAINING,
374
+ transactionId: crypto.randomUUID(),
375
+ targetSessionId,
376
+ queuedTargetSessionId: null,
377
+ attempts: 0,
378
+ requestedCount: 1,
379
+ requestedAt: nowIso,
380
+ drainingStartedAt: nowIso,
381
+ restartRequestedAt: null,
382
+ forceAfterAt: null,
383
+ forceRequestedAt: null,
384
+ spawnDeadlineAt: null,
385
+ awaitingRebindAt: null,
386
+ rebindDeadlineAt: null,
387
+ retryAt: null,
388
+ retryBackoffMs: null,
389
+ retryPhase: null,
390
+ rebindConfirmedAt: null,
391
+ lastRebindSignalAt: null,
392
+ lastRebindSignalSessionId: null,
393
+ lastRebindSignalConversationId: null,
394
+ lastRebindSignalTransactionId: null,
395
+ lastRebindOutcome: null,
396
+ lastRebindErrorCode: null,
397
+ lastRebindErrorRetryable: false,
398
+ lastRebindErrorTerminal: false,
399
+ readyAt: null,
400
+ completedAt: null,
401
+ lastFailurePhase: null,
402
+ lastFailureCode: null,
403
+ lastFailureRetryable: false,
404
+ lastFailureTerminal: false,
405
+ lastFailureAt: null,
406
+ terminalOutcomePhase: null,
407
+ terminalOutcomeCode: null,
408
+ terminalOutcomeMessage: null,
409
+ terminalOutcomeAttempts: 0,
410
+ terminalOutcomeAt: null,
411
+ lastError: reason || null,
412
+ });
413
+ }
414
+
415
+ function requestRestart({ targetSessionId, reason = null } = {}) {
416
+ const target = String(targetSessionId || '').trim();
417
+ if (!target) {
418
+ const state = writeState({ lastError: 'missing target session id' });
419
+ return { ok: false, error: 'missing-target-session', state };
420
+ }
421
+
422
+ const current = readState();
423
+ if (current.state === STATES.IDLE) {
424
+ const state = startTransaction(target, reason);
425
+ return { ok: true, accepted: true, queued: false, coalesced: false, state };
426
+ }
427
+
428
+ if (current.targetSessionId === target) {
429
+ const state = writeState({
430
+ requestedCount: Number(current.requestedCount || 0) + 1,
431
+ lastError: reason || current.lastError || null,
432
+ });
433
+ return { ok: true, accepted: true, queued: false, coalesced: true, state };
434
+ }
435
+
436
+ const state = writeState({
437
+ queuedTargetSessionId: target,
438
+ lastError: reason || current.lastError || null,
439
+ });
440
+ return { ok: true, accepted: true, queued: true, coalesced: false, state };
441
+ }
442
+
443
+ function maybeStartQueuedTarget() {
444
+ const current = readState();
445
+ if (current.state !== STATES.IDLE) return current;
446
+ if (!current.queuedTargetSessionId) return current;
447
+ return startTransaction(current.queuedTargetSessionId, current.lastError || null);
448
+ }
449
+
450
+ function markReady() {
451
+ const current = readState();
452
+ if (current.state !== STATES.AWAITING_REBIND && current.state !== STATES.RESTARTING) return current;
453
+ const nowIso = isoNow(now);
454
+ return writeState({
455
+ state: STATES.READY,
456
+ readyAt: nowIso,
457
+ completedAt: nowIso,
458
+ rebindConfirmedAt: current.rebindConfirmedAt || nowIso,
459
+ forceRequestedAt: current.forceRequestedAt || null,
460
+ rebindDeadlineAt: null,
461
+ retryAt: null,
462
+ retryBackoffMs: null,
463
+ retryPhase: null,
464
+ });
465
+ }
466
+
467
+ function noteCliOffline() {
468
+ const current = readState();
469
+ if (current.state !== STATES.RESTARTING) return current;
470
+ const nowIso = isoNow(now);
471
+ return writeState({
472
+ state: STATES.AWAITING_REBIND,
473
+ awaitingRebindAt: nowIso,
474
+ rebindDeadlineAt: addMs(nowIso, safeRebindTimeoutMs),
475
+ });
476
+ }
477
+
478
+ function noteCliOnline() {
479
+ return readState();
480
+ }
481
+
482
+ function buildRebindResult(base = {}) {
483
+ const state = base.state || readState();
484
+ return {
485
+ ...base,
486
+ state,
487
+ awaitingRebind: state.state === STATES.AWAITING_REBIND,
488
+ expected: {
489
+ transactionId: state.transactionId || null,
490
+ targetSessionId: state.targetSessionId || null,
491
+ },
492
+ };
493
+ }
494
+
495
+ function markFailure({
496
+ state,
497
+ phase,
498
+ code,
499
+ message,
500
+ retryable,
501
+ terminal,
502
+ includeRebindFields = false,
503
+ }) {
504
+ const nowIso = isoNow(now);
505
+ const patch = {
506
+ lastFailurePhase: phase || null,
507
+ lastFailureCode: code || null,
508
+ lastFailureRetryable: !!retryable,
509
+ lastFailureTerminal: !!terminal,
510
+ lastFailureAt: nowIso,
511
+ lastError: message || null,
512
+ };
513
+ if (includeRebindFields) {
514
+ patch.lastRebindOutcome = terminal ? 'rejected' : 'failed';
515
+ patch.lastRebindErrorCode = code || null;
516
+ patch.lastRebindErrorRetryable = !!retryable;
517
+ patch.lastRebindErrorTerminal = !!terminal;
518
+ }
519
+ return writeState({ ...state, ...patch });
520
+ }
521
+
522
+ function finalizeTerminalFailure({
523
+ state,
524
+ phase,
525
+ code,
526
+ message,
527
+ attempts = null,
528
+ includeRebindFields = false,
529
+ }) {
530
+ const nowIso = isoNow(now);
531
+ const active = state || readState();
532
+ return writeState({
533
+ ...active,
534
+ state: STATES.IDLE,
535
+ transactionId: null,
536
+ targetSessionId: null,
537
+ attempts: 0,
538
+ requestedCount: 0,
539
+ requestedAt: null,
540
+ drainingStartedAt: null,
541
+ restartRequestedAt: null,
542
+ forceAfterAt: null,
543
+ forceRequestedAt: null,
544
+ spawnDeadlineAt: null,
545
+ awaitingRebindAt: null,
546
+ rebindDeadlineAt: null,
547
+ retryAt: null,
548
+ retryBackoffMs: null,
549
+ retryPhase: null,
550
+ readyAt: null,
551
+ completedAt: nowIso,
552
+ lastFailurePhase: phase || null,
553
+ lastFailureCode: code || null,
554
+ lastFailureRetryable: false,
555
+ lastFailureTerminal: true,
556
+ lastFailureAt: nowIso,
557
+ terminalOutcomePhase: phase || null,
558
+ terminalOutcomeCode: code || null,
559
+ terminalOutcomeMessage: message || null,
560
+ terminalOutcomeAttempts: Math.max(0, Number(attempts != null ? attempts : (active.attempts || 0))),
561
+ terminalOutcomeAt: nowIso,
562
+ lastError: message || null,
563
+ ...(includeRebindFields ? {
564
+ lastRebindOutcome: 'rejected',
565
+ lastRebindErrorCode: code || null,
566
+ lastRebindErrorRetryable: false,
567
+ lastRebindErrorTerminal: true,
568
+ } : {}),
569
+ });
570
+ }
571
+
572
+ function scheduleRetryOrTerminal({
573
+ state,
574
+ phase,
575
+ code,
576
+ message,
577
+ includeRebindFields = false,
578
+ }) {
579
+ const current = state || readState();
580
+ const attempt = Math.max(1, Number(current.attempts || 1));
581
+ const failed = markFailure({
582
+ state: current,
583
+ phase,
584
+ code,
585
+ message,
586
+ retryable: true,
587
+ terminal: false,
588
+ includeRebindFields,
589
+ });
590
+ if (attempt >= safeMaxAttempts) {
591
+ return finalizeTerminalFailure({
592
+ state: failed,
593
+ phase,
594
+ code: `${code}-exhausted`,
595
+ message: `${message} (attempts exhausted ${attempt}/${safeMaxAttempts})`,
596
+ attempts: attempt,
597
+ includeRebindFields,
598
+ });
599
+ }
600
+ const nowIso = isoNow(now);
601
+ const idx = Math.max(0, Math.min(attempt - 1, safeRetryBackoffMs.length - 1));
602
+ const backoffMs = Number(safeRetryBackoffMs[idx] || safeRetryBackoffMs[safeRetryBackoffMs.length - 1] || 1_000);
603
+ return writeState({
604
+ ...failed,
605
+ state: STATES.DRAINING,
606
+ restartRequestedAt: null,
607
+ forceAfterAt: null,
608
+ forceRequestedAt: null,
609
+ spawnDeadlineAt: null,
610
+ awaitingRebindAt: null,
611
+ rebindDeadlineAt: null,
612
+ drainingStartedAt: nowIso,
613
+ retryAt: addMs(nowIso, backoffMs),
614
+ retryBackoffMs: backoffMs,
615
+ retryPhase: phase,
616
+ });
617
+ }
618
+
619
+ function applySessionSync({
620
+ sdkSessionId,
621
+ conversationId = null,
622
+ correlationId = null,
623
+ targetSessionId = null,
624
+ rebindCompleted = false,
625
+ signalSource = 'session-sync',
626
+ } = {}) {
627
+ const normalizedSdkSessionId = normalizeId(sdkSessionId);
628
+ const normalizedConversationId = normalizeId(conversationId);
629
+ const normalizedCorrelationId = normalizeId(correlationId);
630
+ const normalizedTargetSessionId = normalizeId(targetSessionId);
631
+ const explicitRebindCompletion = parseBoolean(rebindCompleted, false);
632
+ const nowIso = isoNow(now);
633
+ const current = readState();
634
+
635
+ const withSignalState = (patch = {}) => writeState({
636
+ lastRebindSignalAt: nowIso,
637
+ lastRebindSignalSessionId: normalizedSdkSessionId,
638
+ lastRebindSignalConversationId: normalizedConversationId,
639
+ lastRebindSignalTransactionId: normalizedCorrelationId,
640
+ lastRebindOutcome: patch.lastRebindOutcome || null,
641
+ lastRebindErrorCode: patch.lastRebindErrorCode || null,
642
+ lastRebindErrorRetryable: patch.lastRebindErrorRetryable || false,
643
+ lastRebindErrorTerminal: patch.lastRebindErrorTerminal || false,
644
+ ...patch,
645
+ });
646
+
647
+ if (!explicitRebindCompletion) {
648
+ return buildRebindResult({
649
+ ok: true,
650
+ considered: false,
651
+ completed: false,
652
+ reason: 'rebind-signal-missing',
653
+ retryable: true,
654
+ terminal: false,
655
+ state: current,
656
+ });
657
+ }
658
+
659
+ if (current.state !== STATES.AWAITING_REBIND && current.state !== STATES.RESTARTING) {
660
+ const classification = classifyRebindConflict('not-awaiting-rebind');
661
+ const state = withSignalState({
662
+ lastRebindOutcome: 'ignored',
663
+ lastRebindErrorCode: 'not-awaiting-rebind',
664
+ lastRebindErrorRetryable: classification.retryable,
665
+ lastRebindErrorTerminal: classification.terminal,
666
+ });
667
+ return buildRebindResult({
668
+ ok: false,
669
+ conflict: true,
670
+ code: 'not-awaiting-rebind',
671
+ considered: true,
672
+ completed: false,
673
+ retryable: classification.retryable,
674
+ terminal: classification.terminal,
675
+ message: 'Restart orchestrator is not awaiting rebind confirmation.',
676
+ state,
677
+ });
678
+ }
679
+
680
+ if (normalizedCorrelationId && current.transactionId && normalizedCorrelationId !== current.transactionId) {
681
+ const classification = classifyRebindConflict('transaction-mismatch');
682
+ const state = withSignalState({
683
+ lastRebindOutcome: 'rejected',
684
+ lastRebindErrorCode: 'transaction-mismatch',
685
+ lastRebindErrorRetryable: classification.retryable,
686
+ lastRebindErrorTerminal: classification.terminal,
687
+ });
688
+ const terminalState = finalizeTerminalFailure({
689
+ state,
690
+ phase: 'rebind',
691
+ code: 'transaction-mismatch',
692
+ message: `Rebind correlation ${normalizedCorrelationId} does not match active transaction.`,
693
+ attempts: state.attempts,
694
+ includeRebindFields: true,
695
+ });
696
+ return buildRebindResult({
697
+ ok: false,
698
+ conflict: true,
699
+ code: 'transaction-mismatch',
700
+ considered: true,
701
+ completed: false,
702
+ retryable: classification.retryable,
703
+ terminal: classification.terminal,
704
+ message: `Rebind correlation ${normalizedCorrelationId} does not match active transaction.`,
705
+ state: terminalState,
706
+ });
707
+ }
708
+
709
+ if (normalizedTargetSessionId && current.targetSessionId && normalizedTargetSessionId !== current.targetSessionId) {
710
+ const classification = classifyRebindConflict('target-mismatch');
711
+ const state = withSignalState({
712
+ lastRebindOutcome: 'rejected',
713
+ lastRebindErrorCode: 'target-mismatch',
714
+ lastRebindErrorRetryable: classification.retryable,
715
+ lastRebindErrorTerminal: classification.terminal,
716
+ });
717
+ const terminalState = finalizeTerminalFailure({
718
+ state,
719
+ phase: 'rebind',
720
+ code: 'target-mismatch',
721
+ message: `Rebind target ${normalizedTargetSessionId} does not match active orchestrator target.`,
722
+ attempts: state.attempts,
723
+ includeRebindFields: true,
724
+ });
725
+ return buildRebindResult({
726
+ ok: false,
727
+ conflict: true,
728
+ code: 'target-mismatch',
729
+ considered: true,
730
+ completed: false,
731
+ retryable: classification.retryable,
732
+ terminal: classification.terminal,
733
+ message: `Rebind target ${normalizedTargetSessionId} does not match active orchestrator target.`,
734
+ state: terminalState,
735
+ });
736
+ }
737
+
738
+ if (!normalizedSdkSessionId || normalizedSdkSessionId !== current.targetSessionId) {
739
+ const classification = classifyRebindConflict('active-session-mismatch');
740
+ const state = withSignalState({
741
+ lastRebindOutcome: 'rejected',
742
+ lastRebindErrorCode: 'active-session-mismatch',
743
+ lastRebindErrorRetryable: classification.retryable,
744
+ lastRebindErrorTerminal: classification.terminal,
745
+ lastFailurePhase: 'rebind',
746
+ lastFailureCode: 'active-session-mismatch',
747
+ lastFailureRetryable: classification.retryable,
748
+ lastFailureTerminal: classification.terminal,
749
+ lastFailureAt: nowIso,
750
+ lastError: `Rebind acknowledged for ${normalizedSdkSessionId || 'unknown'}, expected ${current.targetSessionId || 'unknown'}.`,
751
+ });
752
+ return buildRebindResult({
753
+ ok: false,
754
+ conflict: true,
755
+ code: 'active-session-mismatch',
756
+ considered: true,
757
+ completed: false,
758
+ retryable: classification.retryable,
759
+ terminal: classification.terminal,
760
+ message: `Rebind acknowledged for ${normalizedSdkSessionId || 'unknown'}, expected ${current.targetSessionId || 'unknown'}.`,
761
+ state,
762
+ });
763
+ }
764
+
765
+ const updated = withSignalState({
766
+ rebindConfirmedAt: nowIso,
767
+ rebindDeadlineAt: null,
768
+ retryAt: null,
769
+ retryBackoffMs: null,
770
+ retryPhase: null,
771
+ lastRebindOutcome: 'completed',
772
+ lastRebindErrorCode: null,
773
+ lastRebindErrorRetryable: false,
774
+ lastRebindErrorTerminal: false,
775
+ lastFailurePhase: null,
776
+ lastFailureCode: null,
777
+ lastFailureRetryable: false,
778
+ lastFailureTerminal: false,
779
+ lastFailureAt: null,
780
+ lastError: null,
781
+ });
782
+ const state = (updated.state === STATES.AWAITING_REBIND || updated.state === STATES.RESTARTING)
783
+ ? markReady()
784
+ : updated;
785
+ return buildRebindResult({
786
+ ok: true,
787
+ considered: true,
788
+ completed: true,
789
+ code: null,
790
+ retryable: false,
791
+ terminal: false,
792
+ signalSource: String(signalSource || '').trim() || 'session-sync',
793
+ state,
794
+ });
795
+ }
796
+
797
+ function onDequeueProbe({ processingCount = 0 } = {}) {
798
+ let state = readState();
799
+ if (state.state === STATES.IDLE && state.queuedTargetSessionId) {
800
+ state = maybeStartQueuedTarget();
801
+ }
802
+
803
+ if (state.state === STATES.IDLE) {
804
+ return { blockDequeue: false, state };
805
+ }
806
+
807
+ if (state.state === STATES.DRAINING) {
808
+ if (state.retryAt) {
809
+ const retryAtMs = new Date(state.retryAt).getTime();
810
+ if (Number.isFinite(retryAtMs) && now() < retryAtMs) {
811
+ return { blockDequeue: true, control: null, state };
812
+ }
813
+ }
814
+ if (Number(processingCount || 0) > 0) {
815
+ const drainingStartedMs = state.drainingStartedAt ? new Date(state.drainingStartedAt).getTime() : 0;
816
+ if (drainingStartedMs > 0 && now() - drainingStartedMs >= safeShutdownTimeoutMs) {
817
+ const terminal = finalizeTerminalFailure({
818
+ state,
819
+ phase: 'shutdown',
820
+ code: 'shutdown-timeout',
821
+ message: `Graceful drain timed out after ${safeShutdownTimeoutMs}ms while queue job was active.`,
822
+ attempts: state.attempts,
823
+ });
824
+ return { blockDequeue: false, control: null, state: terminal };
825
+ }
826
+ return { blockDequeue: true, control: null, state };
827
+ }
828
+ const nowIso = isoNow(now);
829
+ state = writeState({
830
+ state: STATES.RESTARTING,
831
+ attempts: Number(state.attempts || 0) + 1,
832
+ retryAt: null,
833
+ retryBackoffMs: null,
834
+ retryPhase: null,
835
+ restartRequestedAt: nowIso,
836
+ forceAfterAt: addMs(nowIso, safeGracefulTimeoutMs),
837
+ spawnDeadlineAt: addMs(nowIso, safeSpawnTimeoutMs),
838
+ forceRequestedAt: null,
839
+ });
840
+ return { blockDequeue: true, control: buildControlPayload(state, false), state };
841
+ }
842
+
843
+ if (state.state === STATES.RESTARTING) {
844
+ const spawnDeadlineMs = state.spawnDeadlineAt ? new Date(state.spawnDeadlineAt).getTime() : 0;
845
+ if (spawnDeadlineMs > 0 && now() >= spawnDeadlineMs) {
846
+ state = scheduleRetryOrTerminal({
847
+ state,
848
+ phase: 'spawn',
849
+ code: 'spawn-timeout',
850
+ message: `CLI restart/resume did not go offline within ${safeSpawnTimeoutMs}ms.`,
851
+ });
852
+ if (state.state !== STATES.DRAINING) return { blockDequeue: false, control: null, state };
853
+ return { blockDequeue: true, control: null, state };
854
+ }
855
+ const nowMs = now();
856
+ const forceAfterMs = state.forceAfterAt ? new Date(state.forceAfterAt).getTime() : 0;
857
+ const shouldForce = forceAfterMs > 0 && nowMs >= forceAfterMs;
858
+ if (shouldForce && !state.forceRequestedAt) {
859
+ state = writeState({ forceRequestedAt: isoNow(now) });
860
+ }
861
+ return { blockDequeue: true, control: buildControlPayload(state, shouldForce), state };
862
+ }
863
+
864
+ if (state.state === STATES.AWAITING_REBIND) {
865
+ const rebindDeadlineMs = state.rebindDeadlineAt ? new Date(state.rebindDeadlineAt).getTime() : 0;
866
+ if (rebindDeadlineMs > 0 && now() >= rebindDeadlineMs) {
867
+ state = scheduleRetryOrTerminal({
868
+ state,
869
+ phase: 'rebind',
870
+ code: 'rebind-timeout',
871
+ message: `CLI restart resumed but session rebind did not complete within ${safeRebindTimeoutMs}ms.`,
872
+ includeRebindFields: true,
873
+ });
874
+ if (state.state !== STATES.DRAINING) return { blockDequeue: false, control: null, state };
875
+ return { blockDequeue: true, control: null, state };
876
+ }
877
+ return { blockDequeue: true, control: null, state };
878
+ }
879
+
880
+ if (state.state === STATES.READY) {
881
+ const readyAtMs = state.readyAt ? new Date(state.readyAt).getTime() : 0;
882
+ if (readyAtMs > 0 && now() - readyAtMs < safeReadyCooldownMs) {
883
+ return { blockDequeue: true, control: null, state };
884
+ }
885
+ if (state.queuedTargetSessionId) {
886
+ const promoted = startTransaction(state.queuedTargetSessionId, state.lastError || null);
887
+ return { blockDequeue: true, control: null, state: promoted };
888
+ }
889
+ state = writeState({
890
+ state: STATES.IDLE,
891
+ transactionId: null,
892
+ targetSessionId: null,
893
+ attempts: 0,
894
+ requestedCount: 0,
895
+ requestedAt: null,
896
+ drainingStartedAt: null,
897
+ restartRequestedAt: null,
898
+ forceAfterAt: null,
899
+ forceRequestedAt: null,
900
+ spawnDeadlineAt: null,
901
+ awaitingRebindAt: null,
902
+ rebindDeadlineAt: null,
903
+ retryAt: null,
904
+ retryBackoffMs: null,
905
+ retryPhase: null,
906
+ rebindConfirmedAt: state.rebindConfirmedAt || null,
907
+ readyAt: null,
908
+ completedAt: state.completedAt || isoNow(now),
909
+ lastRebindSignalAt: state.lastRebindSignalAt || null,
910
+ lastRebindSignalSessionId: state.lastRebindSignalSessionId || null,
911
+ lastRebindSignalConversationId: state.lastRebindSignalConversationId || null,
912
+ lastRebindSignalTransactionId: state.lastRebindSignalTransactionId || null,
913
+ lastRebindOutcome: state.lastRebindOutcome || null,
914
+ lastRebindErrorCode: state.lastRebindErrorCode || null,
915
+ lastRebindErrorRetryable: state.lastRebindErrorRetryable || false,
916
+ lastRebindErrorTerminal: state.lastRebindErrorTerminal || false,
917
+ lastError: null,
918
+ });
919
+ return { blockDequeue: false, state };
920
+ }
921
+
922
+ return { blockDequeue: false, state };
923
+ }
924
+
925
+ return {
926
+ STATES,
927
+ requestRestart,
928
+ onDequeueProbe,
929
+ noteCliOffline,
930
+ noteCliOnline,
931
+ applySessionSync,
932
+ getState: readState,
933
+ };
934
+ }
935
+