@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
@@ -2,6 +2,7 @@
2
2
  import express from 'express';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
+ import { randomUUID } from 'crypto';
5
6
  import { stripRelayPromptContext } from '../services/relay-prompt-sanitizer.mjs';
6
7
  import { resolveUploadMimeType } from '../services/mime-sniffer.mjs';
7
8
  import { applySafeServedContentHeaders } from '../services/safe-served-content.mjs';
@@ -14,6 +15,15 @@ import {
14
15
  persistConversationModelPreference as persistConversationModelPreferenceTx,
15
16
  } from '../services/conversation-preferences-service.mjs';
16
17
  import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
18
+ import { normalizeWorkerSteeringSnapshot } from '../services/session-worker-registry-service.mjs';
19
+ import {
20
+ buildSteerSettleFailure,
21
+ steerAgentLabelForProvider,
22
+ STEER_SETTLE_FAILED_CODE,
23
+ STEER_SETTLE_FAILED_STABLE_CODE,
24
+ } from '../../shared/steer-settle-failure.mjs';
25
+ import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
26
+ import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
17
27
  import {
18
28
  fetchUsageSummaryPromise,
19
29
  staleUsageSnapshotFromRow,
@@ -27,6 +37,8 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
27
37
  import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
28
38
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
29
39
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
40
+ import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
41
+ import { isLiveResend } from '../services/steer-resend.mjs';
30
42
  import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
31
43
 
32
44
  export const SESSION_WORKER_OWNER_LEASE_MS = 120_000;
@@ -175,12 +187,20 @@ function findRecentDuplicateUserMessage({
175
187
  text = '',
176
188
  now = Date.now(),
177
189
  windowMs = DUPLICATE_USER_MESSAGE_WINDOW_MS,
190
+ // A message the user deliberately sends again (Resend of a steer a Stop
191
+ // cut off) is not a duplicate of its own original, nor of an earlier Resend
192
+ // of it that was cancelled or failed.
193
+ exemptMessageIds = [],
178
194
  } = {}) {
179
195
  const fingerprint = normalizeDuplicateMessageText(text);
180
196
  if (!fingerprint) return null;
181
197
  const maxAgeMs = Math.max(60_000, Number(windowMs) || DUPLICATE_USER_MESSAGE_WINDOW_MS);
198
+ const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
199
+ .map((id) => String(id || '').trim()).filter(Boolean));
200
+ const isExempt = (row) => exempt.has(String(row?.id || '').trim());
182
201
 
183
202
  for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
203
+ if (isExempt(row)) continue;
184
204
  const rowText = normalizeDuplicateMessageText(row?.text || '');
185
205
  if (!rowText || rowText !== fingerprint) continue;
186
206
  const rowTime = Date.parse(row?.timestamp || '');
@@ -191,6 +211,7 @@ function findRecentDuplicateUserMessage({
191
211
  }
192
212
 
193
213
  for (const row of Array.isArray(recentQueueRows) ? recentQueueRows : []) {
214
+ if (isExempt(row)) continue;
194
215
  const rowText = normalizeDuplicateMessageText(row?.text || '');
195
216
  if (!rowText || rowText !== fingerprint) continue;
196
217
  const rowTime = Date.parse(row?.timestamp || '');
@@ -831,23 +852,29 @@ export function dequeuePendingMessage({
831
852
  : stmts.findPending.get(currentIso);
832
853
  }
833
854
  if (!next) return null;
855
+ // One processing attempt = one attempt_id. Minted here for every claim —
856
+ // even for callers that never echo it back — so finalization, requeue, and
857
+ // question writes can be fenced to the attempt that actually owns the row.
858
+ const attemptId = randomUUID();
834
859
  if (routingEnabled && requesterSid && stmts.setProcessingWithWorkerLease) {
835
- stmts.setProcessingWithWorkerLease.run(currentIso, requesterSid, currentIso, leaseExpiresAt, currentIso, next.id);
860
+ stmts.setProcessingWithWorkerLease.run(currentIso, attemptId, requesterSid, currentIso, leaseExpiresAt, currentIso, next.id);
836
861
  return {
837
862
  ...next,
838
863
  status: 'processing',
839
864
  processing_at: currentIso,
865
+ attempt_id: attemptId,
840
866
  owner_sdk_session_id: String(next.owner_sdk_session_id || '').trim() || requesterSid,
841
867
  owner_assigned_at: String(next.owner_assigned_at || '').trim() || currentIso,
842
868
  owner_lease_expires_at: leaseExpiresAt,
843
869
  owner_last_claimed_at: currentIso,
844
870
  };
845
871
  }
846
- stmts.setProcessing.run(currentIso, next.id);
872
+ stmts.setProcessing.run(currentIso, attemptId, next.id);
847
873
  return {
848
874
  ...next,
849
875
  status: 'processing',
850
876
  processing_at: currentIso,
877
+ attempt_id: attemptId,
851
878
  };
852
879
  });
853
880
  return dequeue();
@@ -1127,6 +1154,7 @@ export function buildDequeuedRelayMessage({
1127
1154
  status: msg.status,
1128
1155
  timestamp: msg.timestamp,
1129
1156
  processingAt: msg.processing_at,
1157
+ attemptId: String(msg.attempt_id || '').trim() || null,
1130
1158
  ownerSessionId: String(msg.owner_sdk_session_id || '').trim() || null,
1131
1159
  ownerAssignedAt: msg.owner_assigned_at || null,
1132
1160
  ownerLeaseExpiresAt: msg.owner_lease_expires_at || null,
@@ -1748,6 +1776,10 @@ export function registerMessagesRoutes(app, deps) {
1748
1776
  sanitizeActivityText,
1749
1777
  readSessionTranscriptMessages,
1750
1778
  inFlightStateForConversation,
1779
+ // Resolves the live turn among a conversation's processing rows (mid-turn
1780
+ // steering can leave more than one). Falls back to the newest-processing
1781
+ // query for older wiring/tests that do not supply it.
1782
+ resolveLiveTurnQueueRow = (conversationId) => stmts.getLatestProcessingQueueByConversation.get(conversationId) || null,
1751
1783
  emitToClientsExceptSessionId,
1752
1784
  relayBridgeOwnerService,
1753
1785
  relayRestartOrchestrator,
@@ -1756,6 +1788,10 @@ export function registerMessagesRoutes(app, deps) {
1756
1788
  sessionWorkerRegistry,
1757
1789
  sessionWorkerSupervisor,
1758
1790
  sessionWorkerProcessInspector,
1791
+ // Seams for stopSessionWorkerProcesses (platform, killImpl, isPidAliveImpl,
1792
+ // killTmuxSessionImpl). Tests must inject these instead of relying on the
1793
+ // host OS, so the suite behaves identically on Windows and POSIX.
1794
+ sessionWorkerStopOverrides = null,
1759
1795
  resolveSessionStateRoot,
1760
1796
  fetchUsageSummary = null,
1761
1797
  planUsageService = null,
@@ -1974,6 +2010,9 @@ export function registerMessagesRoutes(app, deps) {
1974
2010
  failureRecord,
1975
2011
  markWorkerError = true,
1976
2012
  executedProvider = null,
2013
+ attemptId = null,
2014
+ // Extra writes that must commit only with the (fenced) failure itself.
2015
+ withinTransaction = null,
1977
2016
  }) {
1978
2017
  const now = new Date().toISOString();
1979
2018
  const responseId = uuidv4();
@@ -1983,9 +2022,13 @@ export function registerMessagesRoutes(app, deps) {
1983
2022
  || (modelOrigin === 'auto' ? 'unknown' : requestedModel)
1984
2023
  || null;
1985
2024
  const modelActual = String(model || '').trim() || null;
2025
+ const fencedAttemptId = String(attemptId || '').trim() || null;
1986
2026
  const tx = db.transaction(() => {
1987
- const result = stmts.setFailed.run(JSON.stringify(failureRecord), messageId);
2027
+ const result = fencedAttemptId
2028
+ ? stmts.setFailedFenced.run(JSON.stringify(failureRecord), messageId, fencedAttemptId)
2029
+ : stmts.setFailed.run(JSON.stringify(failureRecord), messageId);
1988
2030
  if (result.changes === 0) return false;
2031
+ if (typeof withinTransaction === 'function') withinTransaction(now);
1989
2032
  stmts.setQueueResponseMessageId?.run(responseId, messageId);
1990
2033
  stmts.insertMsg.run(
1991
2034
  responseId,
@@ -2000,6 +2043,7 @@ export function registerMessagesRoutes(app, deps) {
2000
2043
  modelActual,
2001
2044
  modelOrigin,
2002
2045
  );
2046
+ stmts.setMessageSourceId?.run(messageId, responseId);
2003
2047
  // Provenance travels with terminal failures too — the original hijack
2004
2048
  // incident's signature was stolen turns dying on 402, which exited
2005
2049
  // exactly here with no executed_provider recorded.
@@ -2092,41 +2136,87 @@ export function registerMessagesRoutes(app, deps) {
2092
2136
  return { responseId, now };
2093
2137
  }
2094
2138
 
2139
+ // Cancels question cards left behind by a superseded processing attempt.
2140
+ // Questions carry the attempt_id of the row state they were created under,
2141
+ // so "stale" is exact: any pending card whose attempt differs from the
2142
+ // attempt the row currently records. Cards belonging to that attempt stay
2143
+ // pending — an answer may still be on its way in. A row with NO current
2144
+ // attempt (finalized by a legacy responder after a requeue cleared it) makes
2145
+ // every attempt-stamped card stale: the empty string matches none of them.
2146
+ // The name the steer-settle wording uses for the runtime that took the
2147
+ // prompt, from the conversation's provider type (the shared mapping the
2148
+ // server runtime's recovery path uses too).
2149
+ function steerSettleAgentLabelForConversation(conversationId) {
2150
+ const id = String(conversationId || '').trim();
2151
+ if (!id) return undefined;
2152
+ return steerAgentLabelForProvider(stmts.getRuntimeSessionByConversation?.get?.(id)?.provider_type);
2153
+ }
2154
+
2155
+ /**
2156
+ * Terminal failure for a row whose prompt the worker's runtime already
2157
+ * consumed — a folded or stopped steer whose settle never landed.
2158
+ * At-most-once: every path that would requeue such a row comes here instead,
2159
+ * since re-delivery would run the prompt a second time. `terminalError` lets
2160
+ * a worker supply the path-accurate wording; the default is the fold wording.
2161
+ */
2162
+ function failConsumedQueueRow(queueRow, { reason = 'recovery', terminalError = null, attemptId = null } = {}) {
2163
+ const agentLabel = steerSettleAgentLabelForConversation(queueRow?.conversation_id);
2164
+ const failure = terminalError && typeof terminalError === 'object'
2165
+ ? { ...buildSteerSettleFailure(queueRow, { agentLabel }), ...terminalError }
2166
+ : buildSteerSettleFailure(queueRow, { agentLabel });
2167
+ const failed = failQueueMessage({
2168
+ queueRow,
2169
+ messageId: queueRow.id,
2170
+ conversationId: queueRow.conversation_id,
2171
+ relayMode: normalizeRelayMode(queueRow.relay_mode) || DEFAULT_RELAY_MODE,
2172
+ model: queueRow.model || null,
2173
+ responseText: buildTerminalFailureTextForChat(failure),
2174
+ failureRecord: {
2175
+ ...failure,
2176
+ kind: 'terminal',
2177
+ error: 'terminal-error',
2178
+ reason: String(reason || 'recovery'),
2179
+ retryCount: Number(queueRow?.retry_count || 0),
2180
+ },
2181
+ markWorkerError: false,
2182
+ attemptId,
2183
+ });
2184
+ if (failed) {
2185
+ settleRelayAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
2186
+ settleRelaySubagentAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
2187
+ console.warn(`[${ts()}] FAILED ${String(queueRow.id).slice(0, 8)} reason=${reason} code=${STEER_SETTLE_FAILED_STABLE_CODE} (consumed; never re-run)`);
2188
+ }
2189
+ return failed;
2190
+ }
2191
+
2192
+ function normalizeConsumedSteerEntries(raw) {
2193
+ const seen = new Set();
2194
+ return (Array.isArray(raw) ? raw : [])
2195
+ .map((entry) => (entry && typeof entry === 'object'
2196
+ ? { id: String(entry.id || '').trim(), attemptId: String(entry.attemptId || '').trim() || null }
2197
+ : { id: String(entry || '').trim(), attemptId: null }))
2198
+ .filter((entry) => {
2199
+ if (!entry.id || seen.has(entry.id)) return false;
2200
+ seen.add(entry.id);
2201
+ return true;
2202
+ })
2203
+ .slice(0, 100);
2204
+ }
2205
+
2095
2206
  function cancelPendingRelayQuestionsForMessage(messageId) {
2096
2207
  const targetMessageId = String(messageId || '').trim();
2097
- if (!targetMessageId || typeof stmts.cancelPendingQuestionsByMessage?.run !== 'function') return 0;
2098
- const pendingRows = typeof stmts.listPendingQuestionsByMessage?.all === 'function'
2099
- ? stmts.listPendingQuestionsByMessage.all(targetMessageId)
2100
- : [];
2101
- if (!pendingRows.length) return 0;
2102
-
2103
- // Get the message start time to determine which questions are "stale"
2208
+ if (!targetMessageId || typeof stmts.cancelPendingQuestionsForStaleAttempts?.all !== 'function') return 0;
2104
2209
  const queueRow = stmts.findQById?.get(targetMessageId) || null;
2105
- const messageStartedAt = queueRow?.created_at || null;
2106
-
2107
- // Only cancel questions that were created BEFORE this turn started (stale orphans)
2108
- // Keep questions created during the turn - they might still be waiting for answers
2210
+ const currentAttemptId = String(queueRow?.attempt_id || '').trim();
2109
2211
  const now = new Date().toISOString();
2110
- let cancelledCount = 0;
2111
-
2112
- for (const row of pendingRows) {
2113
- const questionCreatedAt = row.created_at || null;
2114
- const isStale = messageStartedAt && questionCreatedAt && questionCreatedAt < messageStartedAt;
2115
-
2116
- if (isStale) {
2117
- // This is an orphaned question from a previous attempt - cancel it
2118
- const result = db.prepare(`UPDATE relay_questions SET status = 'cancelled', answered_at = ? WHERE id = ? AND status = 'pending'`).run(now, row.id);
2119
- if ((result?.changes || 0) > 0) {
2120
- cancelledCount++;
2121
- const updated = stmts.getQuestion?.get(row.id) || null;
2122
- if (updated && typeof deps.formatQuestionRow === 'function') {
2123
- io.emit('relay_question_updated', { question: deps.formatQuestionRow(updated) });
2124
- }
2125
- }
2212
+ const cancelledRows = stmts.cancelPendingQuestionsForStaleAttempts.all(now, targetMessageId, currentAttemptId);
2213
+ for (const row of cancelledRows) {
2214
+ const updated = stmts.getQuestion?.get(row.id) || null;
2215
+ if (updated && typeof deps.formatQuestionRow === 'function') {
2216
+ io.emit('relay_question_updated', { question: deps.formatQuestionRow(updated) });
2126
2217
  }
2127
2218
  }
2128
-
2129
- return cancelledCount;
2219
+ return cancelledRows.length;
2130
2220
  }
2131
2221
 
2132
2222
  const strandedPrimeCooldownBySession = new Map();
@@ -2146,7 +2236,7 @@ export function registerMessagesRoutes(app, deps) {
2146
2236
  AND rs.sdk_session_id = ?
2147
2237
  )
2148
2238
  AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
2149
- ORDER BY q.retry_count ASC, CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(q.next_attempt_at, q.timestamp) ASC, q.timestamp ASC
2239
+ ORDER BY q.timestamp ASC, q.rowid ASC
2150
2240
  LIMIT 1
2151
2241
  `);
2152
2242
  const transferPendingOwnerToSession = db.prepare(`
@@ -2221,11 +2311,13 @@ export function registerMessagesRoutes(app, deps) {
2221
2311
  UPDATE queue
2222
2312
  SET status = 'pending',
2223
2313
  processing_at = NULL,
2314
+ attempt_id = NULL,
2224
2315
  next_attempt_at = ?,
2225
2316
  owner_lease_expires_at = NULL
2226
2317
  WHERE id = ?
2227
2318
  AND owner_sdk_session_id = ?
2228
2319
  AND status = 'processing'
2320
+ AND consumed_at IS NULL
2229
2321
  `);
2230
2322
  const findMostRecentConversationForSession = db.prepare(`
2231
2323
  SELECT conversation_id
@@ -2405,15 +2497,7 @@ export function registerMessagesRoutes(app, deps) {
2405
2497
  }
2406
2498
 
2407
2499
  function buildRelayStopFailurePayload() {
2408
- return {
2409
- kind: 'turn-aborted',
2410
- error: 'turn-aborted',
2411
- code: 'turn-aborted',
2412
- stableCode: 'relay.turn-aborted',
2413
- message: 'System note: This turn was stopped from the relay UI before completion.',
2414
- guidance: 'Send a new message to continue when you are ready.',
2415
- failedAt: new Date().toISOString(),
2416
- };
2500
+ return buildRelayStopFailure();
2417
2501
  }
2418
2502
 
2419
2503
  function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
@@ -2496,6 +2580,10 @@ export function registerMessagesRoutes(app, deps) {
2496
2580
  io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
2497
2581
  continue;
2498
2582
  }
2583
+ if (row.consumed_at) {
2584
+ failConsumedQueueRow(row, { reason });
2585
+ continue;
2586
+ }
2499
2587
  const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
2500
2588
  const relayStreamRows = stmts.listStreamEventsByQueueMessage?.all?.(row.id);
2501
2589
  const fallbackRelayActivities = Array.isArray(relayActivityRows)
@@ -2567,6 +2655,21 @@ export function registerMessagesRoutes(app, deps) {
2567
2655
  return rows;
2568
2656
  }
2569
2657
 
2658
+ // A worker survives EPERM probes (alive, different owner), so only ESRCH
2659
+ // and bad pids count as dead — the same read as the supervisor's probe.
2660
+ function isWorkerPidAlive(pidValue) {
2661
+ const pid = Number(pidValue);
2662
+ if (!Number.isInteger(pid) || pid <= 0) return false;
2663
+ try {
2664
+ process.kill(pid, 0);
2665
+ return true;
2666
+ } catch (error) {
2667
+ const code = String(error?.code || '').trim().toUpperCase();
2668
+ if (code === 'EPERM') return true;
2669
+ return false;
2670
+ }
2671
+ }
2672
+
2570
2673
  app.post('/api/session-worker/:sdkSessionId/kill', auth, async (req, res) => {
2571
2674
  const sdkSessionId = normalizeSessionWorkerId(req.params.sdkSessionId);
2572
2675
  if (!sdkSessionId) return res.status(400).json({ error: 'Missing session worker id' });
@@ -2574,73 +2677,52 @@ export function registerMessagesRoutes(app, deps) {
2574
2677
  const currentWorker = sessionWorkerRegistry?.getWorker?.(sdkSessionId) || null;
2575
2678
  sessionWorkerSupervisor?.markKilled?.(sdkSessionId);
2576
2679
  try {
2577
- await sessionWorkerSupervisor?.cancelPendingStart?.(sdkSessionId);
2680
+ // Wait for an in-flight spawn to settle before enumerating processes:
2681
+ // a fire-and-forget cancellation let the spawn's child appear right
2682
+ // after the scan and survive the kill.
2683
+ await sessionWorkerSupervisor?.cancelPendingStart?.(sdkSessionId, { wait: true });
2578
2684
  } catch {
2579
2685
  // If cancellation fails, keep going with the process kill cleanup.
2580
2686
  }
2581
2687
 
2582
- // Collect ALL matching PIDs (not just first)
2583
- const discoveredProcesses = process.platform === 'win32'
2584
- ? (
2585
- sessionWorkerProcessInspector?.findWindowsProcessTreeForSession?.(sdkSessionId)
2586
- || sessionWorkerProcessInspector?.findWindowsProcessesForSession?.(sdkSessionId)
2587
- || sessionWorkerProcessInspector?.findProcessesForSession?.(sdkSessionId)
2588
- || []
2589
- )
2590
- : (sessionWorkerProcessInspector?.findProcessesForSession?.(sdkSessionId) || []);
2591
- const allPids = [...new Set([
2592
- ...discoveredProcesses.map((p) => (p.processId ? Number(p.processId) : null)).filter(Boolean),
2593
- currentWorker?.pid ? Number(currentWorker.pid) : null,
2594
- ].filter(Boolean))];
2688
+ // Deterministic stop — the shared service enumerates every matching PID,
2689
+ // signals, polls, escalates once, and verifies, instead of the one-shot
2690
+ // scan-and-signal pass that used to report success with survivors.
2691
+ const stopped = await stopSessionWorkerProcesses({
2692
+ sdkSessionId,
2693
+ worker: currentWorker,
2694
+ processInspector: sessionWorkerProcessInspector,
2695
+ isPidAliveImpl: isWorkerPidAlive,
2696
+ killTmuxSessionImpl: killTmuxSession,
2697
+ ...(sessionWorkerStopOverrides || {}),
2698
+ });
2699
+ const allPids = stopped.pids || [];
2700
+ const remainingPids = stopped.remainingPids || [];
2701
+ const killedPids = allPids.filter((pid) => !remainingPids.includes(pid));
2702
+ const processStatus = allPids.length
2703
+ ? (stopped.ok ? 'killed' : 'kill-incomplete')
2704
+ : 'not-running';
2595
2705
 
2596
- let processStatus = 'not-running';
2597
- let killedPids = [];
2706
+ // Audit marker — always emitted before responding
2707
+ console.log(`[KILL] session=${sdkSessionId} pids=${allPids.join(',') || 'none'} processStatus=${processStatus}${remainingPids.length ? ` remaining=${remainingPids.join(',')}` : ''}`);
2598
2708
 
2599
- if (allPids.length) {
2600
- try {
2601
- if (process.platform === 'win32') {
2602
- // On Windows, kill through PowerShell to avoid process-group edge-cases.
2603
- killedPids = sessionWorkerProcessInspector.stopWindowsPids(allPids);
2604
- } else {
2605
- killTmuxSession(sdkSessionId);
2606
- for (const pid of allPids) {
2607
- try {
2608
- process.kill(pid, 'SIGTERM');
2609
- killedPids.push(pid);
2610
- } catch {
2611
- // Ignore missing/already-dead processes.
2612
- }
2613
- }
2614
- await delay(150);
2615
- for (const pid of allPids) {
2616
- try {
2617
- process.kill(pid, 0);
2618
- process.kill(pid, 'SIGKILL');
2619
- } catch {
2620
- // Already exited or inaccessible.
2621
- }
2622
- }
2623
- }
2624
- processStatus = 'killed';
2625
- } catch (error) {
2626
- return res.status(500).json({ error: error?.message || 'Failed to kill session worker', pids: allPids });
2627
- }
2709
+ if (!stopped.ok) {
2710
+ // Verified survivors (or a failed stop pass): keep the registry entry
2711
+ // and every owned queue row. Clearing them while a CLI is still alive is
2712
+ // what let a "successful" kill leave an orphan worker holding the
2713
+ // session's work.
2714
+ return res.status(409).json({
2715
+ ok: false,
2716
+ error: stopped.error || 'Session worker processes survived the kill',
2717
+ sdkSessionId,
2718
+ killedPids,
2719
+ remainingPids,
2720
+ processStatus,
2721
+ escalated: stopped.escalated === true,
2722
+ timedOut: stopped.timedOut === true,
2723
+ });
2628
2724
  }
2629
2725
 
2630
- // Audit marker — always emitted before responding
2631
- console.log(`[KILL] session=${sdkSessionId} pids=${allPids.join(',') || 'none'} processStatus=${processStatus}`);
2632
-
2633
- // Post-kill verification — synchronous re-scan to confirm processes are gone
2634
- const remainingPids = allPids.length
2635
- ? (process.platform === 'win32'
2636
- ? (
2637
- sessionWorkerProcessInspector?.findWindowsProcessTreeForSession?.(sdkSessionId)
2638
- || sessionWorkerProcessInspector?.findWindowsProcessesForSession?.(sdkSessionId)
2639
- || []
2640
- ).map((p) => p.processId).filter(Boolean)
2641
- : ((sessionWorkerProcessInspector?.findProcessesForSession?.(sdkSessionId) || []).map((p) => p.processId).filter(Boolean)))
2642
- : [];
2643
-
2644
2726
  sessionWorkerRegistry?.removeWorker?.(sdkSessionId);
2645
2727
  sessionWorkerSupervisor?.clearRestartSchedule?.(sdkSessionId);
2646
2728
  sessionWorkerSupervisor?.resetHealth?.(sdkSessionId, { clearFailureCount: false });
@@ -2749,7 +2831,10 @@ export function registerMessagesRoutes(app, deps) {
2749
2831
  return rejectSessionBinding(res, sessionState.status, sessionState.error);
2750
2832
  }
2751
2833
 
2752
- const queueRow = stmts.getLatestProcessingQueueByConversation.get(conversationId) || null;
2834
+ // The live turn, not merely the newest processing row: a steered message
2835
+ // folded into the running turn is also `processing`, and Stop must target
2836
+ // the turn actually executing (which the worker interrupts as a whole).
2837
+ const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
2753
2838
  if (!queueRow) {
2754
2839
  return res.json({
2755
2840
  ok: true,
@@ -2948,6 +3033,44 @@ export function registerMessagesRoutes(app, deps) {
2948
3033
  });
2949
3034
  });
2950
3035
 
3036
+ // The session worker that can still pull a processing row back out of its
3037
+ // runtime: the row's owner first (the worker that claimed it), then the
3038
+ // conversation's bound session. Only a worker whose latest heartbeat lists
3039
+ // the id as cancellable qualifies — the relay never guesses at the runtime's
3040
+ // queue state.
3041
+ function findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId }) {
3042
+ if (typeof sessionWorkerRegistry?.getWorker !== 'function') return null;
3043
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
3044
+ const candidates = [...new Set([
3045
+ normalizeSessionWorkerId(queueRow?.owner_sdk_session_id),
3046
+ normalizeSessionWorkerId(conversation?.sdk_session_id),
3047
+ ].filter(Boolean))];
3048
+ for (const sessionId of candidates) {
3049
+ const worker = sessionWorkerRegistry.getWorker(sessionId);
3050
+ const cancellableIds = Array.isArray(worker?.steering?.cancellableIds) ? worker.steering.cancellableIds : [];
3051
+ if (cancellableIds.includes(messageId)) return worker;
3052
+ }
3053
+ return null;
3054
+ }
3055
+
3056
+ // Asks the owning worker to remove a pushed-but-unconsumed row from its
3057
+ // runtime (`cancel_pushed_message`). True when the control went out over a
3058
+ // live worker socket; the worker then settles the row through
3059
+ // POST /api/queue-cancelled. False (no such worker, id not cancellable, or
3060
+ // the socket is gone) leaves the row on its ordinary path.
3061
+ function requestPushedMessageCancel({ queueRow, conversationId, messageId }) {
3062
+ const worker = findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId });
3063
+ if (!worker) return false;
3064
+ const sent = sendWorkerControl?.(worker.sdkSessionId, {
3065
+ type: 'cancel_pushed_message',
3066
+ messageId,
3067
+ conversationId,
3068
+ });
3069
+ if (!sent) return false;
3070
+ console.log(`[${ts()}] UNSTEER REQ ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
3071
+ return true;
3072
+ }
3073
+
2951
3074
  app.post('/api/conversation/:conversationId/cancel-queued-turn', auth, (req, res) => {
2952
3075
  const conversationId = String(req.params.conversationId || '').trim();
2953
3076
  const requestedMessageId = String(req.body?.messageId || '').trim();
@@ -2981,6 +3104,19 @@ export function registerMessagesRoutes(app, deps) {
2981
3104
 
2982
3105
  const currentStatus = String(queueRow.status || '').trim().toLowerCase();
2983
3106
  if (currentStatus === 'processing') {
3107
+ // Un-steer: a row the owning worker pushed into its runtime but reports
3108
+ // as still unconsumed (heartbeat `steering.cancellableIds`) can be pulled
3109
+ // back out. The worker answers through POST /api/queue-cancelled once the
3110
+ // runtime confirms the removal; until then the row stays processing.
3111
+ if (requestPushedMessageCancel({ queueRow, conversationId, messageId: requestedMessageId })) {
3112
+ return res.json({
3113
+ ok: true,
3114
+ cancelled: false,
3115
+ acknowledgement: 'cancel-requested',
3116
+ requestedMessageId,
3117
+ status: currentStatus,
3118
+ });
3119
+ }
2984
3120
  return res.json({
2985
3121
  ok: true,
2986
3122
  cancelled: false,
@@ -3853,6 +3989,52 @@ export function registerMessagesRoutes(app, deps) {
3853
3989
  });
3854
3990
  });
3855
3991
 
3992
+ // A Resend of a steer a Stop cut off unanswered. Honored only for a user
3993
+ // message of this conversation whose own settle marker is kind='stopped' and
3994
+ // whose text is what is being sent; anything else is an ordinary send (and
3995
+ // gets the ordinary duplicate guard). Returns { originId } to honor it,
3996
+ // { alreadyResentAs } when an earlier Resend of it still stands (from any
3997
+ // device, at any time), or null.
3998
+ function resolveStoppedSteerResend(conversationId, rawMessageId, text) {
3999
+ const messageId = String(rawMessageId || '').trim();
4000
+ if (!messageId || !conversationId) return null;
4001
+ try {
4002
+ const original = db.prepare(`
4003
+ SELECT id, text, mode FROM messages WHERE id = ? AND conversation_id = ? AND role = 'user'
4004
+ `).get(messageId, conversationId);
4005
+ if (!original) return null;
4006
+ // Stored user text may carry the relay's prompt context; the incoming
4007
+ // text is already stripped, so compare like with like.
4008
+ const originalText = stripRelayPromptContext(original.text, original.mode);
4009
+ if (normalizeDuplicateMessageText(originalText) !== normalizeDuplicateMessageText(text)) return null;
4010
+ const stopped = db.prepare(`
4011
+ SELECT 1 FROM messages
4012
+ WHERE conversation_id = ? AND role = 'assistant' AND kind = 'stopped'
4013
+ AND (source_message_id = ? OR id IN (SELECT response_message_id FROM queue WHERE id = ?))
4014
+ LIMIT 1
4015
+ `).get(conversationId, messageId, messageId);
4016
+ if (!stopped) return null;
4017
+ const earlier = db.prepare(`
4018
+ SELECT m.id,
4019
+ q.id AS queue_id,
4020
+ q.status AS queue_status,
4021
+ EXISTS (SELECT 1 FROM messages a WHERE a.role = 'assistant' AND a.source_message_id = m.id) AS answered
4022
+ FROM messages m
4023
+ LEFT JOIN queue q ON q.id = m.id
4024
+ WHERE m.conversation_id = ? AND m.role = 'user' AND m.resend_of_message_id = ?
4025
+ `).all(conversationId, messageId);
4026
+ const standing = earlier.find((row) => isLiveResend({
4027
+ hasQueueRow: !!row.queue_id,
4028
+ queueStatus: row.queue_status,
4029
+ answered: !!row.answered,
4030
+ }));
4031
+ if (standing) return { alreadyResentAs: standing.id };
4032
+ return { originId: messageId, exemptIds: [messageId, ...earlier.map((row) => row.id)] };
4033
+ } catch {
4034
+ return null;
4035
+ }
4036
+ }
4037
+
3856
4038
  // POST /api/message — browser sends a message
3857
4039
  app.post('/api/message', auth, async (req, res) => {
3858
4040
  const {
@@ -3879,6 +4061,10 @@ export function registerMessagesRoutes(app, deps) {
3879
4061
  const requestedRelayMode = normalizeRelayMode(relayMode || mode);
3880
4062
  const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
3881
4063
  const normalizedAttachments = normalizeAttachments(rawAttachments);
4064
+ const requestedAttachmentCount = Math.min(
4065
+ MAX_UPLOAD_ATTACHMENTS,
4066
+ Array.isArray(rawAttachments) ? rawAttachments.filter((item) => item && typeof item === 'object').length : (rawAttachments ? 1 : 0),
4067
+ );
3882
4068
  const referenceResolution = collectReferenceAttachmentsFromText(trimmedText);
3883
4069
  const attachments = mergeMessageAttachments(normalizedAttachments, referenceResolution.attachments);
3884
4070
  const imageTarget = rawImageTarget && typeof rawImageTarget === 'object'
@@ -4348,6 +4534,19 @@ export function registerMessagesRoutes(app, deps) {
4348
4534
  }
4349
4535
  }
4350
4536
 
4537
+ const resend = shouldCreateConversation
4538
+ ? null
4539
+ : resolveStoppedSteerResend(conversationId, req.body.resendOfMessageId, trimmedText);
4540
+ if (resend?.alreadyResentAs) {
4541
+ return res.json({
4542
+ ok: true,
4543
+ duplicate: true,
4544
+ alreadyResent: true,
4545
+ duplicateOfMessageId: resend.alreadyResentAs,
4546
+ conversationId,
4547
+ });
4548
+ }
4549
+ const resendOriginId = resend?.originId || null;
4351
4550
  if (!shouldCreateConversation) {
4352
4551
  const recentUserMessages = stmts.getRecentMessagesDesc.all(conversationId, 20);
4353
4552
  const recentQueueRows = db.prepare(`
@@ -4361,6 +4560,7 @@ export function registerMessagesRoutes(app, deps) {
4361
4560
  recentUserMessages,
4362
4561
  recentQueueRows,
4363
4562
  text: trimmedText,
4563
+ exemptMessageIds: resend?.exemptIds || [],
4364
4564
  });
4365
4565
  if (duplicateMessage) {
4366
4566
  return res.json({
@@ -4466,27 +4666,32 @@ export function registerMessagesRoutes(app, deps) {
4466
4666
  );
4467
4667
  linkUploadReferences(convId, msgId, attachments);
4468
4668
  stmts.updateConvTime.run(now, convId);
4469
- if (typeof stmts.updateConvDraft?.run === 'function') {
4470
- stmts.updateConvDraft.run(null, now, sessionId || null, convId);
4471
- } else {
4472
- db.prepare(`
4473
- UPDATE conversations
4474
- SET draft_text = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4475
- WHERE id = ?
4476
- `).run(now, sessionId || null, convId);
4477
- }
4478
- // The message now owns these blobs, so the draft placeholders can go. The
4479
- // sent-message references were just inserted above, so nothing is orphaned.
4480
- if (typeof stmts.updateConvDraftAttachments?.run === 'function') {
4481
- stmts.updateConvDraftAttachments.run(null, now, sessionId || null, convId);
4482
- } else {
4483
- db.prepare(`
4484
- UPDATE conversations
4485
- SET draft_attachments = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4486
- WHERE id = ?
4487
- `).run(now, sessionId || null, convId);
4669
+ if (resendOriginId) stmts.setMessageResendOf?.run(resendOriginId, msgId);
4670
+ // A Resend never came from the composer: whatever the user is typing
4671
+ // there stays their draft.
4672
+ if (!resendOriginId) {
4673
+ if (typeof stmts.updateConvDraft?.run === 'function') {
4674
+ stmts.updateConvDraft.run(null, now, sessionId || null, convId);
4675
+ } else {
4676
+ db.prepare(`
4677
+ UPDATE conversations
4678
+ SET draft_text = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4679
+ WHERE id = ?
4680
+ `).run(now, sessionId || null, convId);
4681
+ }
4682
+ // The message now owns these blobs, so the draft placeholders can go. The
4683
+ // sent-message references were just inserted above, so nothing is orphaned.
4684
+ if (typeof stmts.updateConvDraftAttachments?.run === 'function') {
4685
+ stmts.updateConvDraftAttachments.run(null, now, sessionId || null, convId);
4686
+ } else {
4687
+ db.prepare(`
4688
+ UPDATE conversations
4689
+ SET draft_attachments = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
4690
+ WHERE id = ?
4691
+ `).run(now, sessionId || null, convId);
4692
+ }
4693
+ stmts.deleteDraftUploadRefs?.run?.(convId);
4488
4694
  }
4489
- stmts.deleteDraftUploadRefs?.run?.(convId);
4490
4695
  // Cursor allows per-turn model switching, so the pinned provider_model has
4491
4696
  // to follow the accepted model; otherwise the conversation list and the
4492
4697
  // composer keep restoring the model chosen at bootstrap.
@@ -4576,14 +4781,16 @@ export function registerMessagesRoutes(app, deps) {
4576
4781
  }
4577
4782
  throw error;
4578
4783
  }
4579
- io.emit('conversation_draft_updated', {
4580
- conversationId: convId,
4581
- draftText: '',
4582
- draftAttachments: [],
4583
- draftUpdatedAt: now,
4584
- draftUpdatedByClientId: sessionId || null,
4585
- senderClientId: sessionId || null,
4586
- });
4784
+ if (!resendOriginId) {
4785
+ io.emit('conversation_draft_updated', {
4786
+ conversationId: convId,
4787
+ draftText: '',
4788
+ draftAttachments: [],
4789
+ draftUpdatedAt: now,
4790
+ draftUpdatedByClientId: sessionId || null,
4791
+ senderClientId: sessionId || null,
4792
+ });
4793
+ }
4587
4794
  if (ownerSessionId) {
4588
4795
  const existingWorker = sessionWorkerRegistry?.getWorker?.(ownerSessionId) || null;
4589
4796
  sessionWorkerRegistry?.upsertWorker?.({
@@ -4699,6 +4906,9 @@ export function registerMessagesRoutes(app, deps) {
4699
4906
  ...workspaceRootPayload(),
4700
4907
  referenceAttachmentCount: referenceResolution.attachments.length,
4701
4908
  skippedReferenceAttachments: referenceResolution.skipped,
4909
+ // Attachment references that no longer resolve (a deleted upload) are
4910
+ // dropped silently by normalizeAttachments; the sender is told how many.
4911
+ droppedAttachmentCount: Math.max(0, requestedAttachmentCount - normalizedAttachments.length),
4702
4912
  imageOperation: imageOperation
4703
4913
  ? {
4704
4914
  id: imageOperation.id,
@@ -4713,6 +4923,8 @@ export function registerMessagesRoutes(app, deps) {
4713
4923
  touchCli();
4714
4924
  const requester = readBridgeIdentity(req);
4715
4925
  const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
4926
+ const settleFailedHandled = [];
4927
+ const settleFailedSkipped = [];
4716
4928
  const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
4717
4929
  // A persistent-process worker can hold several live rows at once (the
4718
4930
  // running turn plus a delivered message queued behind it plus a
@@ -4748,7 +4960,71 @@ export function registerMessagesRoutes(app, deps) {
4748
4960
  pid: normalizedRequesterPid,
4749
4961
  });
4750
4962
  }
4963
+ // The worker's composer-facing steering snapshot rides the heartbeat;
4964
+ // store it on the registry entry (status payload spreads it to clients)
4965
+ // only when it actually changed, so quiet heartbeats stay write-free.
4966
+ // Normalized with the registry's own rules BEFORE comparing — comparing
4967
+ // raw against stored would never converge for values the registry drops.
4968
+ const steering = normalizeWorkerSteeringSnapshot(req.body?.steering);
4969
+ if (steering) {
4970
+ const workerForSteering = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
4971
+ const stored = workerForSteering?.steering || null;
4972
+ const storedCancellableIds = Array.isArray(stored?.cancellableIds) ? stored.cancellableIds : [];
4973
+ const changed = !stored
4974
+ || stored.turnActive !== steering.turnActive
4975
+ || stored.canSteer !== steering.canSteer
4976
+ || (stored.holdReason || null) !== (steering.holdReason || null)
4977
+ || (stored.messageId || null) !== (steering.messageId || null)
4978
+ // The Copilot SDK worker's opt-in and its un-steerable set: a flip
4979
+ // in either must reach the status payload (composer gate, bubble
4980
+ // Cancel), so it counts as a change like the hold fields do.
4981
+ || (stored.supported === true) !== (steering.supported === true)
4982
+ || storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n');
4983
+ if (workerForSteering && changed) {
4984
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
4985
+ // Pushed rather than left for the clients' status poll: the
4986
+ // composer's Steer/Queue label and the pushed rows' Cancel read it,
4987
+ // and the poll would add up to 4 s to a heartbeat that already lags.
4988
+ io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
4989
+ }
4990
+ }
4751
4991
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
4992
+ // Consumed steers whose settle the worker could not save within its
4993
+ // retry budget (e.g. its writes keep failing): the relay fails them
4994
+ // itself with the worker's terminal error, never requeueing them.
4995
+ const settleFailedEntries = Array.isArray(req.body?.settleFailed) ? req.body.settleFailed.slice(0, 50) : [];
4996
+ const requesterConversationForSettle = String(requester?.conversationId || '').trim() || null;
4997
+ for (const entry of settleFailedEntries) {
4998
+ const id = String(entry?.id || '').trim();
4999
+ const row = id ? stmts.findQById.get(id) : null;
5000
+ if (!row || row.status !== 'processing') {
5001
+ if (id) settleFailedHandled.push(id);
5002
+ continue;
5003
+ }
5004
+ // Only the worker that owns the row may have it failed, and only in a
5005
+ // conversation that worker serves.
5006
+ // Anything else is acknowledged as skipped: the worker has no claim on
5007
+ // the row, so it stops reporting it (and releases its hold), and the
5008
+ // row takes the ordinary recovery path — where a consumed mark still
5009
+ // fails it rather than re-running it.
5010
+ const rowSession = normalizeSessionWorkerId(stmts.getRuntimeSessionByConversation?.get(row.conversation_id)?.sdk_session_id);
5011
+ if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId
5012
+ || (rowSession !== requesterSessionId && String(row.conversation_id) !== String(requesterConversationForSettle || ''))) {
5013
+ settleFailedSkipped.push(id);
5014
+ continue;
5015
+ }
5016
+ const attemptId = String(entry?.attemptId || '').trim() || null;
5017
+ if (attemptId && String(row.attempt_id || '') !== attemptId) {
5018
+ settleFailedHandled.push(id);
5019
+ continue;
5020
+ }
5021
+ failConsumedQueueRow(row, {
5022
+ reason: 'settle-failed',
5023
+ terminalError: entry?.terminalError && typeof entry.terminalError === 'object' ? entry.terminalError : null,
5024
+ attemptId,
5025
+ });
5026
+ settleFailedHandled.push(id);
5027
+ }
4752
5028
  if (activeQueueMessageIds.length) {
4753
5029
  const now = new Date().toISOString();
4754
5030
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
@@ -4767,7 +5043,91 @@ export function registerMessagesRoutes(app, deps) {
4767
5043
  }
4768
5044
  relayBridgeOwnerService?.observe?.(requester);
4769
5045
  const { pendingCount } = queueCounts();
4770
- res.json({ ok: true, pendingCount });
5046
+ res.json({
5047
+ ok: true,
5048
+ pendingCount,
5049
+ ...(settleFailedHandled.length ? { settleFailedHandled } : {}),
5050
+ ...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
5051
+ });
5052
+ });
5053
+
5054
+ // POST /api/queue-consumed — the Claude worker marks rows whose prompt the
5055
+ // CLI has taken when no response carries the mark (a turn the user stopped,
5056
+ // a turn handed off mid-reply). Consumed rows are at-most-once: recovery
5057
+ // fails them terminally instead of re-running them.
5058
+ app.post('/api/queue-consumed', auth, (req, res) => {
5059
+ touchCli();
5060
+ const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
5061
+ if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
5062
+ const conversationId = String(req.body?.conversationId || '').trim();
5063
+ const entries = normalizeConsumedSteerEntries(req.body?.entries);
5064
+ if (!conversationId || !entries.length) return res.status(400).json({ error: 'Missing conversationId or entries' });
5065
+ // `consumed: false` un-marks a steer the CLI then replayed as a turn of
5066
+ // its own. Either way only the owning worker, on the row's current
5067
+ // attempt, may touch the mark.
5068
+ const statement = req.body?.consumed === false ? stmts.clearQueueConsumed : stmts.markQueueConsumed;
5069
+ const at = new Date().toISOString();
5070
+ let changed = 0;
5071
+ for (const entry of entries) {
5072
+ changed += Number(statement?.run({
5073
+ at,
5074
+ id: entry.id,
5075
+ conversationId,
5076
+ owner: requesterSessionId,
5077
+ attemptId: entry.attemptId,
5078
+ })?.changes || 0);
5079
+ }
5080
+ res.json({ ok: true, changed });
5081
+ });
5082
+
5083
+ // POST /api/queue-cancelled — the owning worker reports that a pushed but
5084
+ // still unconsumed row was removed from its runtime (an un-steer the user
5085
+ // asked for through cancel-queued-turn → `cancel_pushed_message`). The row
5086
+ // ends exactly like a pending cancel: deleted, its open cards cancelled, no
5087
+ // assistant row, `message_status: cancelled`. Fenced like /api/queue-consumed:
5088
+ // only the row's owner, on the row's current attempt, may settle it.
5089
+ app.post('/api/queue-cancelled', auth, (req, res) => {
5090
+ touchCli();
5091
+ const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
5092
+ if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
5093
+ const conversationId = String(req.body?.conversationId || '').trim();
5094
+ const messageId = String(req.body?.messageId || '').trim();
5095
+ const attemptId = String(req.body?.attemptId || '').trim() || null;
5096
+ if (!conversationId || !messageId) return res.status(400).json({ error: 'Missing conversationId or messageId' });
5097
+
5098
+ const row = stmts.findQById.get(messageId) || null;
5099
+ if (!row) return res.status(404).json({ error: 'not-found' });
5100
+ if (String(row.conversation_id || '').trim() !== conversationId) {
5101
+ return res.status(409).json({ error: 'conversation-mismatch' });
5102
+ }
5103
+ const status = String(row.status || '').trim().toLowerCase();
5104
+ if (status !== 'processing') return res.status(409).json({ error: 'not-processing', status });
5105
+ if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId) {
5106
+ return res.status(409).json({ error: 'not-owned' });
5107
+ }
5108
+ const rowAttemptId = String(row.attempt_id || '').trim() || null;
5109
+ if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
5110
+ console.warn(`[${ts()}] STALE ATTEMPT queue-cancelled ${messageId.slice(0, 8)} attempt=${attemptId.slice(0, 8)} current=${rowAttemptId.slice(0, 8)}`);
5111
+ return res.status(409).json({ error: 'stale_attempt' });
5112
+ }
5113
+
5114
+ // Same transaction shape as the pending cancel above: delete first, so the
5115
+ // question cancel sees no current attempt and flips every pending card of
5116
+ // the row (with foreign keys on, the cascade has already taken them).
5117
+ const cancelPushedTurn = db.transaction(() => {
5118
+ stmts.deleteQueueById.run(messageId);
5119
+ cancelPendingRelayQuestionsForMessage(messageId);
5120
+ });
5121
+ cancelPushedTurn();
5122
+ // A processing row may carry open Stop controls (the live-turn picker can
5123
+ // land on it before it has output); they fail like on a requeue so no
5124
+ // caller waits on a row that no longer exists.
5125
+ settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
5126
+ settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
5127
+
5128
+ io.emit('message_status', { messageId, conversationId, status: 'cancelled' });
5129
+ console.log(`[${ts()}] UNSTEERED ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
5130
+ res.json({ ok: true, cancelled: true });
4771
5131
  });
4772
5132
 
4773
5133
  // POST /api/grok-native-session — Grok worker persists the ACP session id so
@@ -6056,10 +6416,49 @@ export function registerMessagesRoutes(app, deps) {
6056
6416
  });
6057
6417
  });
6058
6418
 
6419
+ const SETTLED_STEER_KINDS = new Set(['folded', 'stopped']);
6420
+
6059
6421
  // POST /api/response — CLI submits response
6060
6422
  app.post('/api/response', auth, async (req, res) => {
6061
6423
  touchCli();
6062
6424
  const { messageId, conversationId, text, model, mode, generatedImages: rawGeneratedImages } = req.body;
6425
+ // A steering absorption (Claude worker): this reply ends where a message
6426
+ // pushed mid-turn was picked up, and the turn continues on that message's
6427
+ // own row. Stamped as kind='absorbed' so the transcript renders the two
6428
+ // turns as one merged flow.
6429
+ // A worker not yet respawned onto 0.9.3 still stamps its fold stubs
6430
+ // absorbed:true; the stub text identifies them, and 'absorbed' on a fold
6431
+ // would mis-merge the next ordinary message.
6432
+ const staleFoldStub = req.body.absorbed === true && String(text || '').trim() === STEER_FOLDED_TEXT;
6433
+ const absorbed = req.body.absorbed === true && !staleFoldStub;
6434
+ // The other settle stubs of a consumed steer (never re-run — the CLI saw
6435
+ // it): 'folded' was answered by the turn it was steered into and renders
6436
+ // as a marker on its own message; 'stopped' was steered into a turn the
6437
+ // user then stopped, so it went unanswered and the client offers Resend.
6438
+ const requestedKind = staleFoldStub ? 'folded' : String(req.body.kind || '').trim();
6439
+ const settledKind = absorbed
6440
+ ? 'absorbed'
6441
+ : (SETTLED_STEER_KINDS.has(requestedKind) ? requestedKind : null);
6442
+ // Steers pushed into the turn this response finishes and not yet settled:
6443
+ // the CLI has taken their prompts, so they are marked consumed together
6444
+ // with this commit and no recovery path may ever re-run them.
6445
+ const consumedSteerEntries = normalizeConsumedSteerEntries(req.body.consumedSteerIds)
6446
+ .filter((entry) => entry.id !== String(messageId || '').trim());
6447
+ // Runs inside the response's fenced write (both the success and the
6448
+ // terminal-failure transaction), so a superseded attempt's late publish
6449
+ // can never mark rows; the steers must share the finishing row's owner.
6450
+ const markSteersConsumed = (conversationIdForMark, atIso) => {
6451
+ const owner = normalizeSessionWorkerId(q?.owner_sdk_session_id);
6452
+ for (const entry of consumedSteerEntries) {
6453
+ stmts.markQueueConsumed?.run({
6454
+ at: atIso,
6455
+ id: entry.id,
6456
+ conversationId: conversationIdForMark,
6457
+ owner,
6458
+ attemptId: entry.attemptId,
6459
+ });
6460
+ }
6461
+ };
6063
6462
  const trimmedText = String(text || '').trim();
6064
6463
  const terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
6065
6464
  // Final digests of background workflows that settled during this turn —
@@ -6090,6 +6489,21 @@ export function registerMessagesRoutes(app, deps) {
6090
6489
  if (!targetConversationId) return res.status(400).json({ error: 'Missing conversationId' });
6091
6490
  const responseBridgeIdentity = readBridgeIdentity(req);
6092
6491
 
6492
+ // Attempt fencing: a writer that names its processing attempt may only
6493
+ // settle the row while that attempt is still the row's current one. A
6494
+ // mismatch means the row was requeued (and possibly re-dequeued) after
6495
+ // this writer started — its work is superseded, not mergeable. Writers
6496
+ // that send no attemptId (the extension engine, mid-rollout workers) keep
6497
+ // the legacy conditional semantics.
6498
+ const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
6499
+ if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
6500
+ console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=response claimed=${claimedAttemptId.slice(0,8)} current=${String(q.attempt_id || '').slice(0,8) || 'none'} status=${q.status}`);
6501
+ return res.status(409).json({ error: 'stale_attempt', currentStatus: q.status });
6502
+ }
6503
+ if (!claimedAttemptId && q && q.status === 'processing') {
6504
+ console.log(`[${ts()}] FENCE ${messageId?.slice(0,8)} legacy unfenced response write`);
6505
+ }
6506
+
6093
6507
  if (q && q.status === 'done') {
6094
6508
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=already_done`);
6095
6509
  return res.json({ ok: true, ignored: 'already_done' });
@@ -6145,8 +6559,16 @@ export function registerMessagesRoutes(app, deps) {
6145
6559
  requestId: terminalFailure.requestId || null,
6146
6560
  failedAt: new Date().toISOString(),
6147
6561
  },
6562
+ attemptId: claimedAttemptId,
6563
+ // An errored turn still took the steers pushed into it.
6564
+ withinTransaction: (atIso) => markSteersConsumed(targetConversationId, atIso),
6148
6565
  });
6149
6566
  if (!failed) {
6567
+ const currentRow = stmts.findQById?.get(messageId) || null;
6568
+ if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
6569
+ console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=response-terminal claimed=${claimedAttemptId.slice(0,8)} current=${String(currentRow.attempt_id || '').slice(0,8) || 'none'} status=${currentRow.status}`);
6570
+ return res.status(409).json({ error: 'stale_attempt', currentStatus: String(currentRow.status || 'unknown') });
6571
+ }
6150
6572
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing`);
6151
6573
  return res.json({ ok: true, ignored: 'not_pending_or_processing' });
6152
6574
  }
@@ -6243,8 +6665,11 @@ export function registerMessagesRoutes(app, deps) {
6243
6665
  ? stmts.getOperation?.get(q.image_operation_id)
6244
6666
  : null;
6245
6667
  const finalize = db.transaction(() => {
6246
- const result = stmts.setDone.run(resolvedText, messageId);
6668
+ const result = claimedAttemptId
6669
+ ? stmts.setDoneFenced.run(resolvedText, messageId, claimedAttemptId)
6670
+ : stmts.setDone.run(resolvedText, messageId);
6247
6671
  if (result.changes === 0) return false;
6672
+ markSteersConsumed(targetConversationId, now);
6248
6673
  stmts.setQueueResponseMessageId?.run(responseId, messageId);
6249
6674
  stmts.insertMsg.run(
6250
6675
  responseId,
@@ -6259,8 +6684,11 @@ export function registerMessagesRoutes(app, deps) {
6259
6684
  explicitModel,
6260
6685
  modelOrigin,
6261
6686
  );
6687
+ stmts.setMessageSourceId?.run(messageId, responseId);
6262
6688
  if (String(q?.kind || '') === 'continuation') {
6263
6689
  stmts.setMessageKind?.run('continuation', responseId);
6690
+ } else if (settledKind) {
6691
+ stmts.setMessageKind?.run(settledKind, responseId);
6264
6692
  }
6265
6693
  // The workflow cards persist atomically with the assistant message they
6266
6694
  // annotate — keyed directly on the response id (see workflow_runs DDL).
@@ -6353,6 +6781,10 @@ export function registerMessagesRoutes(app, deps) {
6353
6781
  });
6354
6782
  const currentRow = stmts.findQById?.get(messageId) || null;
6355
6783
  const currentStatus = String(currentRow?.status || 'unknown');
6784
+ if (claimedAttemptId && currentRow && String(currentRow.attempt_id || '') !== claimedAttemptId) {
6785
+ console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=response-finalize claimed=${claimedAttemptId.slice(0,8)} current=${String(currentRow.attempt_id || '').slice(0,8) || 'none'} status=${currentStatus}`);
6786
+ return res.status(409).json({ error: 'stale_attempt', currentStatus });
6787
+ }
6356
6788
  console.log(`[${ts()}] RESPONSE ${messageId?.slice(0,8)} ignored=not_pending_or_processing actual_status=${currentStatus}`);
6357
6789
  // Ensure UI is updated even if the response was duplicate/late
6358
6790
  if (currentStatus === 'done') {
@@ -6484,6 +6916,9 @@ export function registerMessagesRoutes(app, deps) {
6484
6916
  messageId: responseId,
6485
6917
  message: {
6486
6918
  role: 'assistant',
6919
+ // In the message too, as on reload: a stopped marker's Resend finds
6920
+ // its original through it.
6921
+ sourceMessageId: messageId,
6487
6922
  text: resolvedText,
6488
6923
  attachments: emittedImageAttachments.length ? emittedImageAttachments : undefined,
6489
6924
  model: resolvedAssistantModel,
@@ -6495,18 +6930,25 @@ export function registerMessagesRoutes(app, deps) {
6495
6930
  activities,
6496
6931
  thoughts,
6497
6932
  executedProvider,
6498
- kind: String(q?.kind || '') === 'continuation' ? 'continuation' : undefined,
6933
+ kind: String(q?.kind || '') === 'continuation'
6934
+ ? 'continuation'
6935
+ : (settledKind || undefined),
6499
6936
  // Live-appended messages must show their workflow cards without a
6500
6937
  // reload; conversation reloads serve the same digests from the DB.
6501
6938
  workflowRuns: workflowRuns.length ? workflowRuns : undefined,
6502
6939
  },
6503
6940
  });
6504
6941
  io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
6505
- void pushDispatchService?.notifyTurnComplete?.({
6506
- conversationId: targetConversationId,
6507
- messageId: responseId,
6508
- text: resolvedText,
6509
- });
6942
+ // An absorbed row is not a finished turn — the reply continues on the
6943
+ // steered message's row, whose own completion sends the one notification.
6944
+ // A stopped steer has no reply at all.
6945
+ if (!settledKind) {
6946
+ void pushDispatchService?.notifyTurnComplete?.({
6947
+ conversationId: targetConversationId,
6948
+ messageId: responseId,
6949
+ text: resolvedText,
6950
+ });
6951
+ }
6510
6952
  cancelPendingRelayQuestionsForMessage(messageId);
6511
6953
  res.json({ ok: true });
6512
6954
  });
@@ -6547,27 +6989,56 @@ export function registerMessagesRoutes(app, deps) {
6547
6989
  || normalizeSessionWorkerId(req.body?.sdkSessionId)
6548
6990
  || String(conversation.sdk_session_id || conversationId);
6549
6991
  const relayMode = normalizeRelayMode(req.body?.relayMode) || DEFAULT_RELAY_MODE;
6992
+ // Idempotent registration: the worker mints one operation id before its
6993
+ // first attempt and repeats it on every retry, so a retry whose previous
6994
+ // request committed but lost its response resolves to the same row
6995
+ // instead of minting a twin that nobody will ever settle.
6996
+ const operationId = String(req.body?.operationId || '').trim() || null;
6997
+ const respondWithExistingRow = (row) => {
6998
+ if (String(row.conversation_id || '') !== conversationId) {
6999
+ return res.status(409).json({ error: 'Continuation operation id is bound to another conversation' });
7000
+ }
7001
+ console.log(`[${ts()}] CONTINUATION ${String(row.id).slice(0, 8)} conv=${conversationId.slice(0, 8)} reused op=${operationId.slice(0, 8)}`);
7002
+ return res.json({ ok: true, messageId: row.id, conversationId: row.conversation_id, attemptId: row.attempt_id || null, existing: true });
7003
+ };
7004
+ if (operationId) {
7005
+ const existing = stmts.findQByContinuationOp?.get?.(operationId) || null;
7006
+ if (existing) return respondWithExistingRow(existing);
7007
+ }
6550
7008
  const messageId = uuidv4();
7009
+ const attemptId = uuidv4();
6551
7010
  const now = new Date().toISOString();
6552
7011
  const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
6553
- stmts.insertContinuationQ.run(
6554
- messageId,
6555
- conversationId,
6556
- runtimeSession?.id || null,
6557
- String(req.body?.model || '').trim() || null,
6558
- relayMode,
6559
- '[background continuation]',
6560
- now,
6561
- now,
6562
- ownerSessionId,
6563
- now,
6564
- leaseExpiresAt,
6565
- now,
6566
- );
6567
- console.log(`[${ts()}] CONTINUATION ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)} owner=${ownerSessionId.slice(0, 8)} trigger=${String(req.body?.trigger || 'background_task')}`);
7012
+ try {
7013
+ stmts.insertContinuationQ.run(
7014
+ messageId,
7015
+ conversationId,
7016
+ runtimeSession?.id || null,
7017
+ String(req.body?.model || '').trim() || null,
7018
+ relayMode,
7019
+ '[background continuation]',
7020
+ now,
7021
+ now,
7022
+ ownerSessionId,
7023
+ now,
7024
+ leaseExpiresAt,
7025
+ now,
7026
+ attemptId,
7027
+ operationId,
7028
+ );
7029
+ } catch (error) {
7030
+ // Lost the race against a concurrent duplicate registration: the unique
7031
+ // index on continuation_op_id guarantees the winner's row exists now.
7032
+ if (operationId && String(error?.code || '').startsWith('SQLITE_CONSTRAINT')) {
7033
+ const existing = stmts.findQByContinuationOp?.get?.(operationId) || null;
7034
+ if (existing) return respondWithExistingRow(existing);
7035
+ }
7036
+ throw error;
7037
+ }
7038
+ console.log(`[${ts()}] CONTINUATION ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)} owner=${ownerSessionId.slice(0, 8)} trigger=${String(req.body?.trigger || 'background_task')}${operationId ? ` op=${operationId.slice(0, 8)}` : ''}`);
6568
7039
  io.emit('message_status', { messageId, conversationId, status: 'processing', kind: 'continuation' });
6569
7040
  io.emit('queue_updated', { continuation: 1 });
6570
- res.json({ ok: true, messageId, conversationId });
7041
+ res.json({ ok: true, messageId, conversationId, attemptId });
6571
7042
  });
6572
7043
 
6573
7044
  // POST /api/background-tasks — a session worker publishes its live
@@ -6585,10 +7056,14 @@ export function registerMessagesRoutes(app, deps) {
6585
7056
  startedAt: Number(task?.startedAt) || null,
6586
7057
  summary: String(task?.summary || '').trim().slice(0, 500) || null,
6587
7058
  lastToolName: String(task?.lastToolName || '').trim() || null,
7059
+ lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
6588
7060
  totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
6589
7061
  subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
6590
7062
  model: String(task?.model || '').trim().slice(0, 120) || null,
6591
7063
  modelInherited: task?.modelInherited === true,
7064
+ // Copilot detached shells cannot be stopped from the host side; the
7065
+ // panel hides the Stop button for them. Absent/true = stoppable.
7066
+ stoppable: task?.stoppable !== false,
6592
7067
  // Sanitized digest or undefined: JSON serialization (store broadcast,
6593
7068
  // conversation payload) drops the key entirely for flat rows.
6594
7069
  workflowProgress: sanitizeWorkflowProgress(task?.workflowProgress) || undefined,
@@ -6616,6 +7091,23 @@ export function registerMessagesRoutes(app, deps) {
6616
7091
  });
6617
7092
 
6618
7093
  // POST /api/activity — relay sends in-flight activity updates (tool/search sections)
7094
+ // Optional attempt fence for the live lanes (stream/thought/activity/
7095
+ // subagent): a writer that names its processing attempt is rejected once
7096
+ // the row's current attempt differs — the row was requeued or re-dequeued
7097
+ // after this writer started, and its output belongs to a superseded turn.
7098
+ // Same-attempt writes pass regardless of row status (trailing writes right
7099
+ // after finalization are legitimate), and writers without an attemptId keep
7100
+ // legacy behavior until the extension engine is retired.
7101
+ function rejectStaleAttemptWrite({ req, res, queueRow, route }) {
7102
+ const claimed = String(req.body?.attemptId || '').trim();
7103
+ if (!claimed || !queueRow) return false;
7104
+ const current = String(queueRow.attempt_id || '');
7105
+ if (current === claimed) return false;
7106
+ console.warn(`[${ts()}] STALE ATTEMPT ${String(queueRow.id || '').slice(0,8)} route=${route} claimed=${claimed.slice(0,8)} current=${current.slice(0,8) || 'none'} status=${queueRow.status}`);
7107
+ res.status(409).json({ error: 'stale_attempt', currentStatus: String(queueRow.status || 'unknown') });
7108
+ return true;
7109
+ }
7110
+
6619
7111
  app.post('/api/activity', auth, (req, res) => {
6620
7112
  touchCli();
6621
7113
  const { messageId, conversationId, text, mode, subagentRunId, metadata } = req.body || {};
@@ -6625,6 +7117,7 @@ export function registerMessagesRoutes(app, deps) {
6625
7117
  }
6626
7118
 
6627
7119
  const q = stmts.findQById.get(messageId);
7120
+ if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'activity' })) return;
6628
7121
  const responseMessageId = q?.response_message_id || null;
6629
7122
  const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
6630
7123
  const normalizedMetadata = sanitizeActivityMetadata(metadata);
@@ -6671,6 +7164,7 @@ export function registerMessagesRoutes(app, deps) {
6671
7164
  if (!queueConversationId || queueConversationId !== requestedConversationId) {
6672
7165
  return res.status(409).json({ error: 'Stream conversationId does not match queue conversation' });
6673
7166
  }
7167
+ if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'stream' })) return;
6674
7168
 
6675
7169
  const isStreamSeqConstraintError = (error) => {
6676
7170
  const message = String(error?.message || '').toLowerCase();
@@ -6791,6 +7285,7 @@ export function registerMessagesRoutes(app, deps) {
6791
7285
  if (!queueConversationId || queueConversationId !== requestedConversationId) {
6792
7286
  return res.status(409).json({ error: 'Thought conversationId does not match queue conversation' });
6793
7287
  }
7288
+ if (rejectStaleAttemptWrite({ req, res, queueRow, route: 'thought' })) return;
6794
7289
 
6795
7290
  const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
6796
7291
  const normalizedReasoningId = String(reasoningId || '').trim() || null;
@@ -6900,6 +7395,7 @@ export function registerMessagesRoutes(app, deps) {
6900
7395
  }
6901
7396
 
6902
7397
  const q = stmts.findQById.get(messageId);
7398
+ if (rejectStaleAttemptWrite({ req, res, queueRow: q, route: 'subagent-run' })) return;
6903
7399
  const existingRun = stmts.getSubagentRun?.get(normalizedSubagentRunId);
6904
7400
  const binding = validateSubagentRunBinding({
6905
7401
  queueRow: q,
@@ -6953,6 +7449,14 @@ export function registerMessagesRoutes(app, deps) {
6953
7449
  const { messageId } = req.body;
6954
7450
  const q = stmts.findQById.get(messageId);
6955
7451
  const terminalFailure = resolveTerminalFailurePayload(req.body);
7452
+ // A fenced requeue from a superseded attempt is refused outright: honoring
7453
+ // it would yank the row out from under the attempt that now owns it (or
7454
+ // terminal-fail a row that has already been requeued to someone else).
7455
+ const claimedAttemptId = String(req.body?.attemptId || '').trim() || null;
7456
+ if (claimedAttemptId && q && String(q.attempt_id || '') !== claimedAttemptId) {
7457
+ console.warn(`[${ts()}] STALE ATTEMPT ${messageId?.slice(0,8)} route=requeue claimed=${claimedAttemptId.slice(0,8)} current=${String(q.attempt_id || '').slice(0,8) || 'none'} status=${q.status}`);
7458
+ return res.status(409).json({ error: 'stale_attempt', currentStatus: String(q.status || 'unknown') });
7459
+ }
6956
7460
  const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
6957
7461
  ? normalizeSessionWorkerId(q?.owner_sdk_session_id)
6958
7462
  : null;
@@ -6980,6 +7484,7 @@ export function registerMessagesRoutes(app, deps) {
6980
7484
  retryCount: Number(q?.retry_count || 0),
6981
7485
  failedAt: new Date().toISOString(),
6982
7486
  },
7487
+ attemptId: claimedAttemptId,
6983
7488
  });
6984
7489
  if (failed) {
6985
7490
  settleRelayAbortControlsForQueueMessage(messageId, {
@@ -7004,6 +7509,11 @@ export function registerMessagesRoutes(app, deps) {
7004
7509
  console.log(`[${ts()}] CONTINUATION DROPPED ${messageId?.slice(0,8)} reason=requeue`);
7005
7510
  return res.json({ ok: true, dropped: 'continuation' });
7006
7511
  }
7512
+ if (q && q.status === 'processing' && q.consumed_at) {
7513
+ // The CLI already took this prompt: any requeue class would run it again.
7514
+ failConsumedQueueRow(q, { reason: 'requeue', attemptId: claimedAttemptId });
7515
+ return res.json({ ok: true, terminal: true, code: STEER_SETTLE_FAILED_STABLE_CODE });
7516
+ }
7007
7517
  if (q && q.status === 'processing' && String(req.body?.code || '').trim() === 'relay.provider-mismatch') {
7008
7518
  // A routing refusal, not a delivery failure: the legacy relay handed
7009
7519
  // back a turn that belongs to a session worker. No retry increment and
@@ -7011,13 +7521,32 @@ export function registerMessagesRoutes(app, deps) {
7011
7521
  // instead of paying a 60s ladder for the relay's mistake.
7012
7522
  db.prepare(`
7013
7523
  UPDATE queue
7014
- SET status = 'pending', processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
7015
- WHERE id = ? AND status = 'processing'
7016
- `).run(messageId);
7524
+ SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
7525
+ WHERE id = ? AND status = 'processing'${claimedAttemptId ? ` AND attempt_id = ?` : ''}
7526
+ `).run(...(claimedAttemptId ? [messageId, claimedAttemptId] : [messageId]));
7017
7527
  io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
7018
7528
  console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
7019
7529
  return res.json({ ok: true, mismatch: true });
7020
7530
  }
7531
+ if (q && q.status === 'processing' && String(req.body?.class || '').trim() === 'steering-held') {
7532
+ // The worker refused to push the message because its live turn is
7533
+ // holding steering (an open question card, a compaction, an adoption).
7534
+ // Not a failure: no retry increment, no backoff, and the owner is kept —
7535
+ // the same worker takes it again the moment it signals ready, which is
7536
+ // when the hold ends, so the message steers into the resumed turn.
7537
+ const result = db.prepare(`
7538
+ UPDATE queue
7539
+ SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
7540
+ WHERE id = ? AND status = 'processing'${claimedAttemptId ? ` AND attempt_id = ?` : ''}
7541
+ `).run(...(claimedAttemptId ? [messageId, claimedAttemptId] : [messageId]));
7542
+ if (result.changes > 0) {
7543
+ settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
7544
+ settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
7545
+ io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
7546
+ console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=steering-held retry=${Number(q.retry_count || 0)}`);
7547
+ }
7548
+ return res.json({ ok: true, held: true });
7549
+ }
7021
7550
  if (q && q.status === 'processing') {
7022
7551
  const retryCount = Number(q.retry_count || 0) + 1;
7023
7552
  if (retryCount >= MAX_REQUEUE_RETRIES) {
@@ -7056,6 +7585,7 @@ export function registerMessagesRoutes(app, deps) {
7056
7585
  SET
7057
7586
  status = ?,
7058
7587
  processing_at = NULL,
7588
+ attempt_id = NULL,
7059
7589
  retry_count = ?,
7060
7590
  next_attempt_at = ?,
7061
7591
  owner_lease_expires_at = NULL,
@@ -7063,7 +7593,7 @@ export function registerMessagesRoutes(app, deps) {
7063
7593
  parked_target_session_id = ?,
7064
7594
  parked_transaction_id = ?,
7065
7595
  parked_reason = ?
7066
- WHERE id = ? AND status = 'processing'
7596
+ WHERE id = ? AND status = 'processing'${claimedAttemptId ? ` AND attempt_id = ?` : ''}
7067
7597
  `).run(
7068
7598
  parkForRestart ? 'parked' : 'pending',
7069
7599
  retryCount,
@@ -7072,7 +7602,7 @@ export function registerMessagesRoutes(app, deps) {
7072
7602
  parkForRestart ? (restartState?.targetSessionId || null) : null,
7073
7603
  parkForRestart ? (restartState?.transactionId || null) : null,
7074
7604
  parkForRestart ? (restartState?.lastError || 'session-rebind-pending') : null,
7075
- messageId,
7605
+ ...(claimedAttemptId ? [messageId, claimedAttemptId] : [messageId]),
7076
7606
  );
7077
7607
  if (result.changes > 0) {
7078
7608
  settleRelayAbortControlsForQueueMessage(messageId, {