@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.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- 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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
:
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
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
|
-
|
|
2597
|
-
|
|
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 (
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
-
|
|
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 (
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
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
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
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({
|
|
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 =
|
|
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'
|
|
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
|
-
|
|
6506
|
-
|
|
6507
|
-
|
|
6508
|
-
|
|
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
|
-
|
|
6554
|
-
|
|
6555
|
-
|
|
6556
|
-
|
|
6557
|
-
|
|
6558
|
-
|
|
6559
|
-
|
|
6560
|
-
|
|
6561
|
-
|
|
6562
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
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, {
|