@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
@@ -205,6 +205,9 @@ const SYNC_DB_VERSION = 1;
205
205
  const SYNC_STORE_NAME = 'outbox';
206
206
  const SYNC_TAG = 'copilot-outbox';
207
207
  const SYNC_MAX_REPLAY_ATTEMPTS = 10;
208
+ // Mirrors DRAFT_FLUSH_MAX_AGE_MS in app/conversation-draft-sync.mjs: a draft
209
+ // flush replayed later than this would overwrite whatever the draft became.
210
+ const SYNC_DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
208
211
 
209
212
  function openSyncDb() {
210
213
  return new Promise((resolve, reject) => {
@@ -271,6 +274,10 @@ async function replaySyncOutbox() {
271
274
  try {
272
275
  const entries = await readSyncEntries(db);
273
276
  for (const { key, value } of entries) {
277
+ if (value?.kind === 'draft-flush' && !(Date.now() - Number(value.createdAt || 0) <= SYNC_DRAFT_FLUSH_MAX_AGE_MS)) {
278
+ await deleteSyncEntry(db, key);
279
+ continue;
280
+ }
274
281
  let response = null;
275
282
  try {
276
283
  response = await fetch(scopeUrl(String(value.path || '').replace(/^\//, '')), {
@@ -12,6 +12,8 @@ When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so
12
12
  At runtime, inject the question and choices from a random item in your quiz pool.
13
13
  Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
14
14
 
15
+ When the user may pick several of the choices (they are not mutually exclusive), set `"multi_select": true` if your ask_user tool offers that field; otherwise begin the question with "Select all that apply:". Either way, expect an answer that lists several choices separated by ", ".
16
+
15
17
  In autopilot, still call ask_user when user input is truly blocking, because the relay bridge can surface the question even when the direct SDK question hook is bypassed.
16
18
 
17
19
  For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.
package/server/relay.mjs CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  } from './windows-terminal-launcher.mjs';
29
29
  import { isValidModelId, normalizeModelIdCandidate } from '../shared/model-id.mjs';
30
30
  import {
31
+ buildModelSnapshotFields,
31
32
  extractModelDescriptors,
32
33
  normalizeContextLimitTokens,
33
34
  } from '../shared/model-descriptors.mjs';
@@ -662,17 +663,28 @@ async function getAvailableModels(session) {
662
663
  if (!isValidModelId(modelId)) continue;
663
664
  const existing = byModelId.get(modelId);
664
665
  const cached = cachedModelMetadataById.get(modelId);
666
+ // Highest-priority source wins per field; a lower-priority list only
667
+ // fills what the better one left null. Every field of the shared
668
+ // descriptor is carried, so the snapshot metadata is as rich as the
669
+ // richest list that answered.
670
+ const pick = (field, normalize = (value) => value ?? null) => existing?.[field]
671
+ ?? normalize(entry?.[field])
672
+ ?? cached?.[field]
673
+ ?? null;
665
674
  byModelId.set(modelId, {
666
675
  modelId,
667
- contextLimitTokens: existing?.contextLimitTokens
668
- ?? normalizeContextLimitTokens(entry?.contextLimitTokens)
669
- ?? cached?.contextLimitTokens
670
- ?? null,
671
- longContextLimitTokens: existing?.longContextLimitTokens
672
- ?? normalizeContextLimitTokens(entry?.longContextLimitTokens)
673
- ?? cached?.longContextLimitTokens
674
- ?? null,
676
+ contextLimitTokens: pick('contextLimitTokens', normalizeContextLimitTokens),
677
+ longContextLimitTokens: pick('longContextLimitTokens', normalizeContextLimitTokens),
675
678
  pricing: existing?.pricing || entry?.pricing || cached?.pricing || null,
679
+ displayName: pick('displayName'),
680
+ vendor: pick('vendor'),
681
+ pickerCategory: pick('pickerCategory'),
682
+ preview: existing?.preview === true || entry?.preview === true || cached?.preview === true,
683
+ contextWindowTokens: pick('contextWindowTokens', normalizeContextLimitTokens),
684
+ maxPromptTokens: pick('maxPromptTokens', normalizeContextLimitTokens),
685
+ supportedEfforts: Array.isArray(existing?.supportedEfforts)
686
+ ? existing.supportedEfforts
687
+ : (Array.isArray(entry?.supportedEfforts) ? entry.supportedEfforts : (cached?.supportedEfforts ?? null)),
676
688
  });
677
689
  }
678
690
  const models = byModelId.size ? [...byModelId.values()] : [...cachedModelMetadataById.values()];
@@ -723,19 +735,9 @@ async function publishModelSnapshot(session, reason = 'standalone-relay', force
723
735
  try {
724
736
  const currentModel = await getCurrentModelId(session);
725
737
  const availableModels = await getAvailableModels(session);
726
- const modelIds = availableModels.map((entry) => entry.modelId);
727
- const contextLimitsByModel = Object.fromEntries(
728
- availableModels
729
- .filter((entry) => entry.contextLimitTokens !== null)
730
- .map((entry) => [entry.modelId, entry.contextLimitTokens]),
731
- );
732
- const modelMetadataByModel = Object.fromEntries(
733
- availableModels.map((entry) => [entry.modelId, {
734
- defaultContextLimitTokens: entry.contextLimitTokens,
735
- longContextLimitTokens: entry.longContextLimitTokens,
736
- pricing: entry.pricing,
737
- }]),
738
- );
738
+ // Same shared builder as the SDK worker and the server-side discovery:
739
+ // one definition of the per-model metadata the relay publishes.
740
+ const { models: modelIds, contextLimitsByModel, modelMetadataByModel } = buildModelSnapshotFields(availableModels);
739
741
  await apiJson('POST', '/api/models/snapshot', {
740
742
  source: `standalone-relay:${reason}`,
741
743
  models: modelIds,
@@ -7,6 +7,9 @@ export function createMessageRepository(db) {
7
7
  const shareVisibility = createShareVisibilityStatements(db);
8
8
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
9
  .some((column) => column.name === 'image_operation_id');
10
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
11
+ const messagesHaveSourceId = messageColumns.has('source_message_id');
12
+ const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
10
13
  const insertQueueSql = queueHasImageOperationId
11
14
  ? `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at, image_operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?, ?)`
12
15
  : `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?)`;
@@ -17,7 +20,7 @@ export function createMessageRepository(db) {
17
20
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
18
21
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
19
22
  getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
20
- getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
23
+ getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
21
24
  insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
22
25
  // Provenance is derived from the authenticated responder identity at
23
26
  // /api/response time, never from the response payload.
@@ -112,12 +115,33 @@ export function createMessageRepository(db) {
112
115
  // A background-continuation turn: born 'processing' and owned by its
113
116
  // session worker — the CLI already started it on its own, so it must
114
117
  // never be handed out as deliverable work.
115
- insertContinuationQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, relay_mode, text, status, kind, timestamp, processing_at, retry_count, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at) VALUES (?, ?, ?, 0, ?, ?, ?, 'processing', 'continuation', ?, ?, 0, ?, ?, ?, ?)`),
118
+ insertContinuationQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, relay_mode, text, status, kind, timestamp, processing_at, retry_count, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at, attempt_id, continuation_op_id) VALUES (?, ?, ?, 0, ?, ?, ?, 'processing', 'continuation', ?, ?, 0, ?, ?, ?, ?, ?, ?)`),
119
+ // Idempotent continuation registration: a worker retry after a lost
120
+ // response resolves to the row its operation id already created.
121
+ findQByContinuationOp: db.prepare(`SELECT * FROM queue WHERE continuation_op_id = ?`),
116
122
  setMessageKind: db.prepare(`UPDATE messages SET kind = ? WHERE id = ?`),
123
+ // The durable answer→prompt link (the queue's copy is pruned), so a
124
+ // reload still anchors the reply under the message it answers.
125
+ setMessageSourceId: messagesHaveSourceId
126
+ ? db.prepare(`UPDATE messages SET source_message_id = ? WHERE id = ?`)
127
+ : null,
128
+ setMessageResendOf: messagesHaveResendOf
129
+ ? db.prepare(`UPDATE messages SET resend_of_message_id = ? WHERE id = ?`)
130
+ : null,
117
131
  // Quiet teardown for a continuation whose worker died: there is no
118
132
  // user to answer, so it fails without the terminal-failure ceremony.
119
133
  dropStaleContinuation: db.prepare(`UPDATE queue SET status = 'failed', processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND kind = 'continuation'`),
120
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
134
+ // Claim ordering (all findPending* variants and the owner-transfer
135
+ // scan): strict send order, rowid breaking same-millisecond ties.
136
+ // Neither retry_count nor next_attempt_at may rank rows: dead-worker
137
+ // recovery and /api/requeue bump retry_count, so ranking by it ran a
138
+ // recovered OLDER message after newer ones in the same conversation,
139
+ // and ranking expired-backoff rows behind fresh arrivals starved them
140
+ // for as long as new messages kept coming (live 2026-09-19: a
141
+ // steer-recovered row waited 5 minutes while newer messages jumped
142
+ // it). Head-of-line protection is the WHERE clause's next_attempt_at
143
+ // backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
144
+ findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY timestamp ASC, rowid ASC LIMIT 1`),
121
145
  // Global fallback for requesters without a session identity (the legacy
122
146
  // Copilot relay CLI). Conversations bound to a session-worker provider
123
147
  // (claude/cursor/grok) are excluded outright: handing one of their turns
@@ -149,10 +173,8 @@ export function createMessageRepository(db) {
149
173
  'github'
150
174
  )) NOT IN (${sessionWorkerProviderSqlList()})
151
175
  ORDER BY
152
- q.retry_count ASC,
153
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
154
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
155
- q.timestamp ASC
176
+ q.timestamp ASC,
177
+ q.rowid ASC
156
178
  LIMIT 1
157
179
  `),
158
180
  findPendingForWorker: db.prepare(`
@@ -195,10 +217,8 @@ export function createMessageRepository(db) {
195
217
  ) = ? THEN 0
196
218
  ELSE 1
197
219
  END ASC,
198
- q.retry_count ASC,
199
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
200
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
201
- q.timestamp ASC
220
+ q.timestamp ASC,
221
+ q.rowid ASC
202
222
  LIMIT 1
203
223
  `),
204
224
  findPendingForSessionAffinity: db.prepare(`
@@ -217,10 +237,8 @@ export function createMessageRepository(db) {
217
237
  NULLIF(c.sdk_session_id, '')
218
238
  ) = ?
219
239
  ORDER BY
220
- q.retry_count ASC,
221
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
222
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
223
- q.timestamp ASC
240
+ q.timestamp ASC,
241
+ q.rowid ASC
224
242
  LIMIT 1
225
243
  `),
226
244
  countQueueWorkForSessionAffinity: db.prepare(`
@@ -267,12 +285,13 @@ export function createMessageRepository(db) {
267
285
  `),
268
286
  countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
269
287
  countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
270
- setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
288
+ setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ?, attempt_id = ? WHERE id = ?`),
271
289
  setProcessingWithWorkerLease: db.prepare(`
272
290
  UPDATE queue
273
291
  SET
274
292
  status = 'processing',
275
293
  processing_at = ?,
294
+ attempt_id = ?,
276
295
  owner_sdk_session_id = COALESCE(NULLIF(owner_sdk_session_id, ''), ?),
277
296
  owner_assigned_at = COALESCE(owner_assigned_at, ?),
278
297
  owner_lease_expires_at = ?,
@@ -281,15 +300,23 @@ export function createMessageRepository(db) {
281
300
  `),
282
301
  setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
283
302
  setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
303
+ // Legacy (unfenced) finalization keeps 'pending' in the set so a
304
+ // response landing after a stale-sweep requeue still finalizes; fenced
305
+ // finalization deliberately does not — a fenced writer whose row went
306
+ // back to pending has by definition been superseded.
284
307
  setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
285
- setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ?`),
308
+ setDoneFenced: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND attempt_id = ?`),
309
+ // The status set mirrors canTerminalFail (processing/pending/parked):
310
+ // failure must never overwrite a row another attempt already settled.
311
+ setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status IN ('processing', 'pending', 'parked')`),
312
+ setFailedFenced: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND attempt_id = ?`),
286
313
  deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
287
314
  findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
288
315
  pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
289
316
  // Recovery clears only the lease, never the owner: a recovered row must
290
317
  // stay routed to its provider worker so the primer respawns that worker
291
318
  // instead of the row becoming claimable by the global relay poll.
292
- recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND processing_at < ?`),
319
+ recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND consumed_at IS NULL AND processing_at < ?`),
293
320
  // Staleness is inactivity, not elapsed turn time: owner_last_claimed_at is
294
321
  // refreshed by every worker heartbeat for the message it is working on, so a
295
322
  // long-but-alive turn keeps moving the cutoff. processing_at is only the
@@ -299,7 +326,7 @@ export function createMessageRepository(db) {
299
326
  // A turn blocked on an unanswered AskUserQuestion is never stale — it is
300
327
  // waiting on the human, and relay_questions carries its own expiry.
301
328
  listRecoverableProcessing: db.prepare(`
302
- SELECT id, conversation_id, kind, relay_mode, model, retry_count
329
+ SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
303
330
  FROM queue
304
331
  WHERE status = 'processing'
305
332
  AND COALESCE(kind, '') != 'continuation'
@@ -313,12 +340,14 @@ export function createMessageRepository(db) {
313
340
  UPDATE queue
314
341
  SET status = 'pending',
315
342
  processing_at = NULL,
343
+ attempt_id = NULL,
316
344
  next_attempt_at = @requeueAt,
317
345
  owner_lease_expires_at = NULL,
318
346
  owner_last_claimed_at = NULL,
319
347
  retry_count = retry_count + 1
320
348
  WHERE status = 'processing'
321
349
  AND COALESCE(kind, '') != 'continuation'
350
+ AND consumed_at IS NULL
322
351
  AND (
323
352
  COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
324
353
  OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
@@ -334,7 +363,7 @@ export function createMessageRepository(db) {
334
363
  // worker can never collect its answer, so holding the row would leak
335
364
  // both the row and the question.
336
365
  listProcessingRowsForOwner: db.prepare(`
337
- SELECT id, conversation_id, kind, retry_count
366
+ SELECT id, conversation_id, kind, retry_count, consumed_at, relay_mode, model
338
367
  FROM queue
339
368
  WHERE status = 'processing'
340
369
  AND NULLIF(owner_sdk_session_id, '') = ?
@@ -343,11 +372,33 @@ export function createMessageRepository(db) {
343
372
  UPDATE queue
344
373
  SET status = 'pending',
345
374
  processing_at = NULL,
375
+ attempt_id = NULL,
346
376
  next_attempt_at = ?,
347
377
  owner_lease_expires_at = NULL,
348
378
  owner_last_claimed_at = NULL,
349
379
  retry_count = retry_count + 1
350
- WHERE id = ? AND status = 'processing'
380
+ WHERE id = ? AND status = 'processing' AND consumed_at IS NULL
381
+ `),
382
+ // See the queue.consumed_at migration: at-most-once rows.
383
+ // Fenced like every other worker write: the row must be processing in
384
+ // this conversation, owned by the same worker session (NULL-safe, for
385
+ // routing-disabled installs), and — when the caller names one — still
386
+ // on the attempt it was pushed under.
387
+ markQueueConsumed: db.prepare(`
388
+ UPDATE queue
389
+ SET consumed_at = COALESCE(consumed_at, @at)
390
+ WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
391
+ AND NULLIF(owner_sdk_session_id, '') IS @owner
392
+ AND (@attemptId IS NULL OR attempt_id = @attemptId)
393
+ `),
394
+ // A consumed steer the CLI then replayed as a turn of its own is an
395
+ // ordinary in-flight row again.
396
+ clearQueueConsumed: db.prepare(`
397
+ UPDATE queue
398
+ SET consumed_at = NULL
399
+ WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
400
+ AND NULLIF(owner_sdk_session_id, '') IS @owner
401
+ AND (@attemptId IS NULL OR attempt_id = @attemptId)
351
402
  `),
352
403
  listProcessingOwnerSessionIds: db.prepare(`
353
404
  SELECT DISTINCT NULLIF(owner_sdk_session_id, '') AS sdk_session_id
@@ -376,6 +427,12 @@ export function createMessageRepository(db) {
376
427
  listConversationIdsWithActiveQueue: db.prepare(`SELECT DISTINCT conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
377
428
  deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
378
429
  getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
430
+ // All processing rows, oldest first. Mid-turn steering means a
431
+ // conversation can have more than one processing row at once (the live
432
+ // turn plus a steered message folded into it), so the single-row query
433
+ // above is ambiguous — callers that need "the live turn" resolve it
434
+ // from this list by which row is actually producing output.
435
+ listProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) ASC`),
379
436
  parkPendingQueueForRestart: db.prepare(`
380
437
  UPDATE queue
381
438
  SET
@@ -3,7 +3,7 @@
3
3
  export function createQuestionRepository(db) {
4
4
  return {
5
5
  // relay questions
6
- insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?)`),
6
+ insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at, attempt_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?, ?)`),
7
7
  getQuestion: db.prepare(`SELECT * FROM relay_questions WHERE id = ?`),
8
8
  findPendingQuestionByMessage: db.prepare(`SELECT * FROM relay_questions WHERE message_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1`),
9
9
  listPendingQuestionsByMessage: db.prepare(`SELECT * FROM relay_questions WHERE message_id = ? AND status = 'pending' ORDER BY created_at ASC`),
@@ -11,6 +11,9 @@ export function createQuestionRepository(db) {
11
11
  listQuestions: db.prepare(`SELECT * FROM relay_questions WHERE status = ? AND (? IS NULL OR conversation_id = ?) ORDER BY created_at ASC`),
12
12
  timeoutQuestion:db.prepare(`UPDATE relay_questions SET status = 'timed_out' WHERE id = ? AND status = 'pending'`),
13
13
  cancelPendingQuestionsByMessage: db.prepare(`UPDATE relay_questions SET status = 'cancelled', answered_at = COALESCE(answered_at, ?) WHERE message_id = ? AND status = 'pending'`),
14
+ // Cards left behind by a superseded processing attempt. NULL-attempt
15
+ // rows (created before the fencing deploy) are deliberately left alone.
16
+ cancelPendingQuestionsForStaleAttempts: db.prepare(`UPDATE relay_questions SET status = 'cancelled', answered_at = COALESCE(answered_at, ?) WHERE message_id = ? AND status = 'pending' AND attempt_id IS NOT NULL AND attempt_id != ? RETURNING id`),
14
17
  deleteConvQuestions: db.prepare(`DELETE FROM relay_questions WHERE conversation_id = ?`),
15
18
  // RETURNING (run via .all) so the expiry log names exactly the rows
16
19
  // that flipped — a separate pre-SELECT could race an answer landing
@@ -22,6 +25,21 @@ export function createQuestionRepository(db) {
22
25
  linkActivityToResponse: db.prepare(`UPDATE relay_activity SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
23
26
  listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
24
27
  listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
28
+ // When a queue row last produced output — its newest stream snapshot
29
+ // (rows are rewritten in place and re-stamped per update) or activity
30
+ // line. The live-turn picker compares rows by this, never by the
31
+ // per-row stream seq. `stream_done` is 1 once the row's main-thread
32
+ // snapshot is final: a row settling after a handoff re-stamps its
33
+ // snapshot with done=1, which must not read as live output.
34
+ getLastOutputAtByQueueMessage: db.prepare(`
35
+ SELECT
36
+ (SELECT MAX(at) FROM (
37
+ SELECT MAX(created_at) AS at FROM relay_stream_events WHERE queue_message_id = @id
38
+ UNION ALL
39
+ SELECT MAX(created_at) AS at FROM relay_activity WHERE queue_message_id = @id
40
+ )) AS last_output_at,
41
+ (SELECT MAX(done) FROM relay_stream_events WHERE queue_message_id = @id AND subagent_run_id IS NULL) AS stream_done
42
+ `),
25
43
  deleteConvActivity: db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`),
26
44
 
27
45
  // relay stream events — every update carries the full text-so-far
@@ -1,9 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
-
5
3
  export function createSessionRepository(db) {
6
- const shareVisibility = createShareVisibilityStatements(db);
7
4
  const runtimeSessionColumns = new Set(
8
5
  db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column?.name || '').trim()),
9
6
  );
@@ -36,36 +33,15 @@ export function createSessionRepository(db) {
36
33
  archiveConv: db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`),
37
34
  deleteConv: db.prepare(`DELETE FROM conversations WHERE id = ?`),
38
35
 
39
- // messages
40
- getMessages: db.prepare(`SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC`),
41
- getSharedMessages: shareVisibility.getSharedMessages,
42
- getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
43
- setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
36
+ // Queue and message statements live in message-repository.mjs — the
37
+ // single source of truth. Copies used to live here too; the runtime's
38
+ // {...sessionRepo, ...messageRepo} spread shadowed all of them, and the
39
+ // dead copies silently fossilized (this one's findPending predated the
40
+ // continuation-row exclusion — claiming through it would have replayed
41
+ // a background continuation as a user prompt). The two counts below are
42
+ // the only message/queue statements this repository actually owns.
44
43
  getConversationMessageCount: db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE conversation_id = ?`),
45
44
  getConversationActiveQueueCount: db.prepare(`SELECT COUNT(*) AS count FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')`),
46
- getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
47
- getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
48
- insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
49
-
50
- // queue
51
- insertQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL)`),
52
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
53
- countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
54
- countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
55
- setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
56
- setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
57
- setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
58
- setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
59
- setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ?`),
60
- deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
61
- findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
62
- pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
63
- recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
64
- listRecoverableProcessing: db.prepare(`SELECT id, conversation_id FROM queue WHERE status = 'processing' AND processing_at < ?`),
65
- recoverProcessingBefore: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
66
- listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
67
- deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
68
- getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
69
45
 
70
46
  // runtime sessions
71
47
  getRuntimeSessionByConversation: db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ?`),
@@ -128,6 +128,15 @@ export function registerAskUserRoutes(app, deps) {
128
128
  if (!q || q.status !== 'processing') {
129
129
  return res.status(409).json({ error: 'No active relay turn' });
130
130
  }
131
+ // Attempt fencing: a caller that names its processing attempt may only
132
+ // open a card while that attempt still owns the row. The stored value is
133
+ // always the row's own attempt_id (server-authoritative), so stale-attempt
134
+ // cleanup covers questions from unfenced callers too.
135
+ const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
136
+ if (claimedAttemptId && String(q.attempt_id || '') !== claimedAttemptId) {
137
+ console.warn(`[${ts()}] STALE ATTEMPT ${String(q.id).slice(0,8)} route=relay-question claimed=${claimedAttemptId.slice(0,8)} current=${String(q.attempt_id || '').slice(0,8) || 'none'}`);
138
+ return res.status(409).json({ error: 'stale_attempt' });
139
+ }
131
140
 
132
141
  const effectiveMessageId = messageId || q.id;
133
142
 
@@ -199,6 +208,7 @@ export function registerAskUserRoutes(app, deps) {
199
208
  continuation.continuationQuestionId,
200
209
  now,
201
210
  expiresAt,
211
+ String(q.attempt_id || '').trim() || null,
202
212
  );
203
213
 
204
214
  const question = formatQuestionRow(stmts.getQuestion.get(questionId));