@oar-sh/oar 0.9.1 → 0.9.3

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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -60,6 +60,54 @@ function normalizeStatus(value) {
60
60
  return ALLOWED_STATUSES.has(mapped) ? mapped : 'new';
61
61
  }
62
62
 
63
+ const STEERING_HOLD_REASONS = new Set(['question', 'compaction', 'adoption', 'delivery', 'other']);
64
+ const STEERING_MESSAGE_ID_MAX_LENGTH = 64;
65
+ const STEERING_CANCELLABLE_IDS_MAX = 50;
66
+
67
+ // Queue message ids the worker still holds pushed-but-unconsumed (the Copilot
68
+ // SDK's queued lane): trimmed strings only, deduped, capped — a worker cannot
69
+ // grow the status payload without bound.
70
+ function normalizeSteeringCancellableIds(raw) {
71
+ if (!Array.isArray(raw)) return [];
72
+ const seen = new Set();
73
+ const ids = [];
74
+ for (const value of raw) {
75
+ if (typeof value !== 'string') continue;
76
+ const id = value.trim();
77
+ if (!id || id.length > STEERING_MESSAGE_ID_MAX_LENGTH || seen.has(id)) continue;
78
+ seen.add(id);
79
+ ids.push(id);
80
+ if (ids.length >= STEERING_CANCELLABLE_IDS_MAX) break;
81
+ }
82
+ return ids;
83
+ }
84
+
85
+ // The worker's composer-facing steering snapshot, published on its heartbeat.
86
+ // Callers follow the registry convention of spreading the existing entry into
87
+ // upserts, so an update without `steering` keeps the last known snapshot.
88
+ // Exported so the heartbeat route compares apples to apples: normalizing there
89
+ // with different rules would make an unknown holdReason never converge and
90
+ // re-upsert on every heartbeat.
91
+ //
92
+ // `supported` is the worker's opt-in to mid-turn steering and the client
93
+ // composer's only steering gate: a worker that never sets it — the extension
94
+ // path, or a Claude worker old enough to send the 4-field snapshot — keeps
95
+ // "Queue". `cancellableIds` lists the pushed rows the worker can still pull
96
+ // back out of its runtime (un-steer).
97
+ export function normalizeWorkerSteeringSnapshot(raw) {
98
+ if (!raw || typeof raw !== 'object') return null;
99
+ const holdReason = String(raw.holdReason || '').trim().toLowerCase();
100
+ const messageId = String(raw.messageId || '').trim().slice(0, STEERING_MESSAGE_ID_MAX_LENGTH);
101
+ return {
102
+ turnActive: raw.turnActive === true,
103
+ canSteer: raw.canSteer === true,
104
+ holdReason: STEERING_HOLD_REASONS.has(holdReason) ? holdReason : null,
105
+ messageId: messageId || null,
106
+ supported: raw.supported === true,
107
+ cancellableIds: normalizeSteeringCancellableIds(raw.cancellableIds),
108
+ };
109
+ }
110
+
63
111
  function sanitizeState(raw = null) {
64
112
  const nowIso = new Date().toISOString();
65
113
  const base = raw && typeof raw === 'object' ? raw : {};
@@ -74,11 +122,23 @@ function sanitizeState(raw = null) {
74
122
  queueDepth: toNonNegativeInt(base.queueDepth, 0),
75
123
  retryCount: toNonNegativeInt(base.retryCount, 0),
76
124
  lastError: String(base.lastError || '').trim() || null,
125
+ steering: normalizeWorkerSteeringSnapshot(base.steering),
77
126
  updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
78
127
  createdAt,
79
128
  };
80
129
  }
81
130
 
131
+ // Statuses that mean the process a snapshot described is gone or not yet
132
+ // replaced by one that has reported. 'error' counts only on the way IN: a
133
+ // live worker marked errored (a requeue retry) keeps heartbeating, and its
134
+ // next snapshot must stick rather than be cleared on every upsert.
135
+ const WORKER_GONE_STATUSES = new Set(['new', 'starting', 'stopped', 'error']);
136
+
137
+ function isWorkerReplacedOrGone(existing, next) {
138
+ if (existing.pid && next.pid !== existing.pid) return true;
139
+ return next.status !== existing.status && WORKER_GONE_STATUSES.has(next.status);
140
+ }
141
+
82
142
  function toSnapshot(entry) {
83
143
  return Object.freeze({ ...entry });
84
144
  }
@@ -91,9 +151,18 @@ export function createSessionWorkerRegistry() {
91
151
 
92
152
  function clearIndexesForSession(entry) {
93
153
  if (!entry) return;
94
- if (entry.workerId) sessionByWorkerId.delete(entry.workerId);
95
- if (entry.conversationId) sessionByConversationId.delete(entry.conversationId);
96
- if (entry.runtimeSessionId) sessionByRuntimeSessionId.delete(entry.runtimeSessionId);
154
+ // Only drop an index that still points at THIS entry's session: a newer
155
+ // entry may have legitimately claimed the same workerId/conversationId/
156
+ // runtimeSessionId since, and deleting blindly would orphan it.
157
+ if (entry.workerId && sessionByWorkerId.get(entry.workerId) === entry.sdkSessionId) {
158
+ sessionByWorkerId.delete(entry.workerId);
159
+ }
160
+ if (entry.conversationId && sessionByConversationId.get(entry.conversationId) === entry.sdkSessionId) {
161
+ sessionByConversationId.delete(entry.conversationId);
162
+ }
163
+ if (entry.runtimeSessionId && sessionByRuntimeSessionId.get(entry.runtimeSessionId) === entry.sdkSessionId) {
164
+ sessionByRuntimeSessionId.delete(entry.runtimeSessionId);
165
+ }
97
166
  }
98
167
 
99
168
  function updateIndexesForSession(entry) {
@@ -154,12 +223,26 @@ export function createSessionWorkerRegistry() {
154
223
  createdAt: existing?.createdAt || state.createdAt,
155
224
  updatedAt: new Date().toISOString(),
156
225
  });
226
+ // A steering snapshot describes one live worker process. Callers spread
227
+ // the old entry into every upsert, so without this a crashed worker's
228
+ // hold outlived it and the composer showed it until a replacement
229
+ // heartbeated — indefinitely, if none ever did.
230
+ if (existing && isWorkerReplacedOrGone(existing, next)) next.steering = null;
157
231
  clearIndexesForSession(existing);
158
232
  bySession.set(state.sdkSessionId, next);
159
233
  updateIndexesForSession(next);
160
234
  return toSnapshot(next);
161
235
  }
162
236
 
237
+ /** Drop a session's steering snapshot (its worker process is known dead). */
238
+ function clearSteering(sdkSessionId) {
239
+ const sessionId = normalizeSessionId(sdkSessionId);
240
+ const existing = sessionId ? bySession.get(sessionId) : null;
241
+ if (!existing?.steering) return false;
242
+ bySession.set(sessionId, { ...existing, steering: null, updatedAt: new Date().toISOString() });
243
+ return true;
244
+ }
245
+
163
246
  function removeWorker(sdkSessionId) {
164
247
  const sessionId = normalizeSessionId(sdkSessionId);
165
248
  if (!sessionId) return false;
@@ -169,6 +252,34 @@ export function createSessionWorkerRegistry() {
169
252
  return bySession.delete(sessionId);
170
253
  }
171
254
 
255
+ // Placeholder→real session rekey (audit #22). Session sync migrates the DB
256
+ // side (conversation binding, runtime session, queue owners) in one
257
+ // transaction; this is the in-memory half, moving the registry entry and its
258
+ // secondary indexes so the placeholder never lingers as a phantom worker.
259
+ function rekeyWorker(fromSdkSessionId, toSdkSessionId) {
260
+ const from = normalizeSessionId(fromSdkSessionId);
261
+ const to = normalizeSessionId(toSdkSessionId);
262
+ if (!from || !to || from === to) return null;
263
+ const placeholder = bySession.get(from);
264
+ if (!placeholder) return null;
265
+ const existingTarget = bySession.get(to) || null;
266
+ // An entry already registered under the real id is newer than the
267
+ // placeholder — its fields win, the placeholder only backfills gaps.
268
+ const next = sanitizeState({
269
+ ...placeholder,
270
+ ...(existingTarget || {}),
271
+ sdkSessionId: to,
272
+ createdAt: placeholder.createdAt,
273
+ updatedAt: new Date().toISOString(),
274
+ });
275
+ clearIndexesForSession(placeholder);
276
+ if (existingTarget) clearIndexesForSession(existingTarget);
277
+ bySession.delete(from);
278
+ bySession.set(to, next);
279
+ updateIndexesForSession(next);
280
+ return toSnapshot(next);
281
+ }
282
+
172
283
  function clearAll() {
173
284
  bySession.clear();
174
285
  sessionByWorkerId.clear();
@@ -187,7 +298,9 @@ export function createSessionWorkerRegistry() {
187
298
  getWorkerByRuntimeSessionId,
188
299
  listWorkers,
189
300
  upsertWorker,
301
+ clearSteering,
190
302
  removeWorker,
303
+ rekeyWorker,
191
304
  clearAll,
192
305
  };
193
306
  }
@@ -482,10 +482,16 @@ export function createSessionWorkerSupervisor({
482
482
  if (current && !isTransitionAllowed(current.status, requestedStatus)) {
483
483
  return current;
484
484
  }
485
+ // The steering snapshot is the worker's to set (its heartbeat) and the
486
+ // registry's to clear (process gone or replaced). Callers build nextState
487
+ // from an older copy of the entry — ensureWorker from the 'starting' one,
488
+ // taken before the new process heartbeated — so passing theirs through
489
+ // would overwrite the snapshot the worker sent in the meantime.
490
+ const { steering: _staleSteering, ...patch } = nextState;
485
491
  const next = registry.upsertWorker({
486
492
  ...(current || {}),
487
493
  sdkSessionId: sessionId,
488
- ...nextState,
494
+ ...patch,
489
495
  status: requestedStatus,
490
496
  updatedAt: nowIso(),
491
497
  });
@@ -85,6 +85,19 @@ function queueSnapshotChanged(left, right) {
85
85
  || left.parkedCount !== right.parkedCount;
86
86
  }
87
87
 
88
+ // The worker link heartbeats every ~10s (readyRefreshMs in
89
+ // worker-websocket-link.mjs) and itself abandons a connection after 30s of
90
+ // server silence (staleConnectionMs). Mirror that on this side: three missed
91
+ // heartbeats and a retained socket no longer proves the worker is alive.
92
+ const WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS = 30_000;
93
+
94
+ // Protocol features this relay understands, advertised in server.hello. A
95
+ // worker that updated on disk before the relay restarted must not rely on
96
+ // them otherwise: an older relay ignores worker.unready / hello ready:false
97
+ // and treats a `steering-held` requeue as a failure (retry, backoff,
98
+ // worker marked errored).
99
+ export const WORKER_PROTOCOL_CAPABILITIES = Object.freeze(['worker-unready', 'steering-held']);
100
+
88
101
  export function createSessionWorkerWebSocketService({
89
102
  WebSocketServerImpl,
90
103
  httpServer,
@@ -104,6 +117,7 @@ export function createSessionWorkerWebSocketService({
104
117
  pathPrefix = '',
105
118
  pollIntervalMs = 1000,
106
119
  nowIso = () => new Date().toISOString(),
120
+ nowMs = () => Date.now(),
107
121
  logger = console,
108
122
  } = {}) {
109
123
  if (!WebSocketServerImpl) throw new Error('createSessionWorkerWebSocketService requires WebSocketServerImpl');
@@ -123,6 +137,66 @@ export function createSessionWorkerWebSocketService({
123
137
  let lastSnapshot = null;
124
138
  let attached = false;
125
139
 
140
+ function nowMsSafe() {
141
+ const value = Number(nowMs());
142
+ return Number.isFinite(value) ? value : Date.now();
143
+ }
144
+
145
+ function removeSocket(socket, reason) {
146
+ const meta = clientState.get(socket);
147
+ clients.delete(socket);
148
+ clientState.delete(socket);
149
+ // Death-detection hook: a worker socket closing while its session owns
150
+ // an in-flight processing row used to be silently forgotten, leaving
151
+ // the row to the 600s stale sweep. The handler PID-probes and recovers.
152
+ if (meta?.sessionId) {
153
+ Promise.resolve(onWorkerSocketClosed({
154
+ sessionId: meta.sessionId,
155
+ pid: meta.pid || null,
156
+ reason,
157
+ })).catch(() => {});
158
+ }
159
+ }
160
+
161
+ function supersedePriorSockets(ws, sessionId) {
162
+ for (const socket of Array.from(clients)) {
163
+ if (socket === ws) continue;
164
+ const meta = clientState.get(socket);
165
+ if (meta?.sessionId !== sessionId) continue;
166
+ // One authoritative socket generation per session: the newest connection
167
+ // wins, and the prior socket is dropped synchronously so it can neither
168
+ // be handed work nor count as liveness while its close event is in
169
+ // flight. Before this, multiple ready sockets could claim one session
170
+ // and each pull rows for it.
171
+ logWarn(`[worker-ws] superseding prior socket for ${sessionId.slice(0, 8)}`);
172
+ removeSocket(socket, 'superseded');
173
+ try { socket.close(); } catch {}
174
+ }
175
+ }
176
+
177
+ function bindSocketIdentity(ws, meta, payload) {
178
+ const claimedSessionId = normalizeText(payload.sessionId);
179
+ const claimedPid = normalizePositiveInt(payload.pid);
180
+ // A socket's identity binds once (upgrade URL or first identifying frame).
181
+ // A later frame claiming a different session or PID is a misidentified or
182
+ // rekeyed worker; closing is safer than ignoring the frame — the worker
183
+ // reconnects and re-binds atomically instead of keeping a socket that
184
+ // receives one session's deliveries while claiming to be another.
185
+ if ((meta.sessionId && claimedSessionId && claimedSessionId !== meta.sessionId)
186
+ || (meta.pid && claimedPid && claimedPid !== meta.pid)) {
187
+ logWarn(`[worker-ws] identity rebind rejected for ${String(meta.sessionId || 'unknown').slice(0, 8)} claimed=${String(claimedSessionId || 'none').slice(0, 8)}`);
188
+ removeSocket(ws, 'identity-rebind');
189
+ try { ws.close(); } catch {}
190
+ return false;
191
+ }
192
+ if (!meta.sessionId && claimedSessionId) {
193
+ meta.sessionId = claimedSessionId;
194
+ supersedePriorSockets(ws, claimedSessionId);
195
+ }
196
+ if (!meta.pid && claimedPid) meta.pid = claimedPid;
197
+ return true;
198
+ }
199
+
126
200
  function toQueueChangedEvent(reason = 'queue-update') {
127
201
  const snapshot = normalizeQueueSnapshot(queueCounts());
128
202
  return {
@@ -302,13 +376,16 @@ export function createSessionWorkerWebSocketService({
302
376
  ready: false,
303
377
  delivering: false,
304
378
  connectedAt: nowIso(),
379
+ lastSeenAtMs: nowMsSafe(),
305
380
  lastDeliveredAt: null,
306
381
  deliveryFailures: 0,
307
382
  });
383
+ if (identity.sessionId) supersedePriorSockets(ws, identity.sessionId);
308
384
  touchCli();
309
385
  emitEvent(ws, {
310
386
  type: 'server.hello',
311
387
  reason: 'connected',
388
+ capabilities: WORKER_PROTOCOL_CAPABILITIES,
312
389
  queue: normalizeQueueSnapshot(queueCounts()),
313
390
  sessionId: identity.sessionId,
314
391
  timestamp: nowIso(),
@@ -325,14 +402,17 @@ export function createSessionWorkerWebSocketService({
325
402
  const meta = clientState.get(ws);
326
403
  if (!meta || !payload || typeof payload !== 'object') return;
327
404
  if (payload.type === 'worker.hello') {
328
- meta.sessionId = normalizeText(payload.sessionId) || meta.sessionId;
329
- meta.pid = normalizePositiveInt(payload.pid) || meta.pid;
330
- meta.ready = true;
405
+ if (!bindSocketIdentity(ws, meta, payload)) return;
406
+ meta.lastSeenAtMs = nowMsSafe();
407
+ // A worker holding deliveries (open question card, compaction) binds
408
+ // its identity with hello but must not be handed work.
409
+ meta.ready = payload.ready !== false;
331
410
  meta.lastHelloAt = nowIso();
332
411
  noteHeartbeat(meta, 'worker-hello');
333
412
  emitEvent(ws, {
334
413
  type: 'server.hello',
335
414
  reason: 'ack',
415
+ capabilities: WORKER_PROTOCOL_CAPABILITIES,
336
416
  queue: normalizeQueueSnapshot(queueCounts()),
337
417
  sessionId: meta.sessionId,
338
418
  timestamp: nowIso(),
@@ -341,17 +421,28 @@ export function createSessionWorkerWebSocketService({
341
421
  return;
342
422
  }
343
423
  if (payload.type === 'worker.ready') {
344
- meta.sessionId = normalizeText(payload.sessionId) || meta.sessionId;
345
- meta.pid = normalizePositiveInt(payload.pid) || meta.pid;
424
+ if (!bindSocketIdentity(ws, meta, payload)) return;
425
+ meta.lastSeenAtMs = nowMsSafe();
346
426
  meta.ready = true;
347
427
  meta.lastReadyAt = nowIso();
348
428
  noteHeartbeat(meta, String(payload.reason || 'worker-ready'));
349
429
  void maybeDeliverToSocket(ws, String(payload.reason || 'worker-ready'));
350
430
  return;
351
431
  }
432
+ if (payload.type === 'worker.unready') {
433
+ // Readiness is otherwise sticky until the next delivery: a ready sent
434
+ // while the worker could steer would still stand after a hold began,
435
+ // and the delivery it drew would only be handed back.
436
+ if (!bindSocketIdentity(ws, meta, payload)) return;
437
+ meta.lastSeenAtMs = nowMsSafe();
438
+ meta.ready = false;
439
+ meta.lastUnreadyAt = nowIso();
440
+ noteHeartbeat(meta, String(payload.reason || 'worker-unready'));
441
+ return;
442
+ }
352
443
  if (payload.type === 'worker.ping') {
353
- meta.sessionId = normalizeText(payload.sessionId) || meta.sessionId;
354
- meta.pid = normalizePositiveInt(payload.pid) || meta.pid;
444
+ if (!bindSocketIdentity(ws, meta, payload)) return;
445
+ meta.lastSeenAtMs = nowMsSafe();
355
446
  meta.lastPingAt = nowIso();
356
447
  noteHeartbeat(meta, String(payload.reason || 'worker-ping'));
357
448
  emitEvent(ws, {
@@ -362,23 +453,8 @@ export function createSessionWorkerWebSocketService({
362
453
  });
363
454
  }
364
455
  });
365
- const dropSocket = (reason) => {
366
- const meta = clientState.get(ws);
367
- clients.delete(ws);
368
- clientState.delete(ws);
369
- // Death-detection hook: a worker socket closing while its session owns
370
- // an in-flight processing row used to be silently forgotten, leaving
371
- // the row to the 600s stale sweep. The handler PID-probes and recovers.
372
- if (meta?.sessionId) {
373
- Promise.resolve(onWorkerSocketClosed({
374
- sessionId: meta.sessionId,
375
- pid: meta.pid || null,
376
- reason,
377
- })).catch(() => {});
378
- }
379
- };
380
- ws.on('close', () => dropSocket('close'));
381
- ws.on('error', () => dropSocket('error'));
456
+ ws.on('close', () => removeSocket(ws, 'close'));
457
+ ws.on('error', () => removeSocket(ws, 'error'));
382
458
  }
383
459
 
384
460
  function hasWorkerSocket(sessionId) {
@@ -390,6 +466,24 @@ export function createSessionWorkerWebSocketService({
390
466
  return false;
391
467
  }
392
468
 
469
+ /**
470
+ * Socket-backed liveness for dead-worker recovery. Mere socket retention is
471
+ * not proof of life — a dead worker's socket lingers until its close event
472
+ * lands — so the socket must have been heard from within the liveness
473
+ * window, or its claimed PID must probe alive.
474
+ */
475
+ function hasLiveWorkerSocket(sessionId) {
476
+ const wanted = normalizeText(sessionId);
477
+ if (!wanted) return false;
478
+ const cutoffMs = nowMsSafe() - WORKER_SOCKET_LIVENESS_MAX_SILENCE_MS;
479
+ for (const meta of clientState.values()) {
480
+ if (meta?.sessionId !== wanted) continue;
481
+ if (Number(meta.lastSeenAtMs || 0) >= cutoffMs) return true;
482
+ if (meta.pid && isWorkerProcessAlive(meta.pid) === true) return true;
483
+ }
484
+ return false;
485
+ }
486
+
393
487
  function start() {
394
488
  if (!attached) {
395
489
  httpServer.on('upgrade', onUpgrade);
@@ -479,5 +573,6 @@ export function createSessionWorkerWebSocketService({
479
573
  handleUpgrade,
480
574
  sendControlToSession,
481
575
  hasWorkerSocket,
576
+ hasLiveWorkerSocket,
482
577
  };
483
578
  }
@@ -91,9 +91,10 @@ export function createStatusEventService(db, {
91
91
  const normalizedConversationId = String(conversationId || '').trim();
92
92
  const dedupeKey = `${token}:${ip}`;
93
93
  const previousSeenAt = sharedAccessDedupe.get(dedupeKey);
94
- sharedAccessDedupe.set(dedupeKey, now);
95
- pruneSharedAccessDedupe(now);
96
94
  if (Number.isFinite(previousSeenAt) && (now - previousSeenAt) <= normalizedDedupeTtlMs) {
95
+ // A dedupe hit slides the window — a persisted event already backs it.
96
+ sharedAccessDedupe.set(dedupeKey, now);
97
+ pruneSharedAccessDedupe(now);
97
98
  return { event: null, deduped: true };
98
99
  }
99
100
 
@@ -113,6 +114,11 @@ export function createStatusEventService(db, {
113
114
  deleteExpiredEvents.run(normalizedMaxEvents);
114
115
  });
115
116
  persist();
117
+ // Committed only after the insert lands: marking the key first made a
118
+ // failed insert report the immediate retry as deduplicated with nothing
119
+ // persisted.
120
+ sharedAccessDedupe.set(dedupeKey, now);
121
+ pruneSharedAccessDedupe(now);
116
122
  return { event, deduped: false };
117
123
  }
118
124
 
@@ -0,0 +1,45 @@
1
+ // Resend of a steer a Stop cut off unanswered (kind='stopped'): shared by the
2
+ // POST /api/message guard and the transcript payload, so "Resent" on screen
3
+ // and the relay's refusal of a second Resend agree.
4
+
5
+ /**
6
+ * Whether an earlier Resend still stands. A cancelled queued message loses
7
+ * its queue row; a failed one keeps it as 'failed'; a finished one may have
8
+ * been pruned from the queue, which its answer (linked by source) proves.
9
+ */
10
+ export function isLiveResend({ hasQueueRow = false, queueStatus = null, answered = false } = {}) {
11
+ if (hasQueueRow) {
12
+ return !['failed', 'cancelled'].includes(String(queueStatus || '').trim().toLowerCase());
13
+ }
14
+ return !!answered;
15
+ }
16
+
17
+ /**
18
+ * Original message id → the id of a Resend of it that still stands, from one
19
+ * conversation's messages (DB rows) and queue rows ({ id, status }).
20
+ */
21
+ export function liveResendsByOriginal(dbMessages = [], queueRows = []) {
22
+ const queueById = new Map((Array.isArray(queueRows) ? queueRows : [])
23
+ .map((row) => [String(row?.id || '').trim(), row]));
24
+ const answeredSources = new Set();
25
+ for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
26
+ if (message?.role !== 'assistant') continue;
27
+ const source = String(message?.source_message_id || '').trim();
28
+ if (source) answeredSources.add(source);
29
+ }
30
+ for (const row of queueById.values()) {
31
+ if (row?.response_message_id) answeredSources.add(String(row.id || '').trim());
32
+ }
33
+ const result = new Map();
34
+ for (const message of Array.isArray(dbMessages) ? dbMessages : []) {
35
+ if (message?.role !== 'user') continue;
36
+ const original = String(message?.resend_of_message_id || '').trim();
37
+ if (!original || result.has(original)) continue;
38
+ const id = String(message?.id || '').trim();
39
+ const queueRow = queueById.get(id);
40
+ if (isLiveResend({ hasQueueRow: !!queueRow, queueStatus: queueRow?.status, answered: answeredSources.has(id) })) {
41
+ result.set(original, id);
42
+ }
43
+ }
44
+ return result;
45
+ }