@oar-sh/oar 0.9.2 → 0.9.4
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 +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- 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-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -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/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- 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.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -15,6 +15,14 @@ import {
|
|
|
15
15
|
persistConversationModelPreference as persistConversationModelPreferenceTx,
|
|
16
16
|
} from '../services/conversation-preferences-service.mjs';
|
|
17
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';
|
|
18
26
|
import { stopSessionWorkerProcesses } from '../services/session-worker-stop-service.mjs';
|
|
19
27
|
import {
|
|
20
28
|
fetchUsageSummaryPromise,
|
|
@@ -29,7 +37,17 @@ import { openAIReasoningEffortsForModel } from '../../shared/openai-reasoning.mj
|
|
|
29
37
|
import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning-effort.mjs';
|
|
30
38
|
import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
31
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';
|
|
32
42
|
import { resolveProviderModelSelection } from '../services/provider-model-selection.mjs';
|
|
43
|
+
import { stripRemotePromptHeader } from '../../shared/remote-relay-contract.mjs';
|
|
44
|
+
import {
|
|
45
|
+
admitRemoteRelayRequest,
|
|
46
|
+
findMentionedRemoteRelays,
|
|
47
|
+
formatRemoteRelayMentionHint,
|
|
48
|
+
recordRemoteRelayUnlocks,
|
|
49
|
+
withRemoteRelayMentionHint,
|
|
50
|
+
} from '../services/remote-relay-inbound.mjs';
|
|
33
51
|
|
|
34
52
|
export const SESSION_WORKER_OWNER_LEASE_MS = 120_000;
|
|
35
53
|
export const SESSION_WORKER_TRANSIENT_DEQUEUE_RETRIES = 2;
|
|
@@ -177,12 +195,20 @@ function findRecentDuplicateUserMessage({
|
|
|
177
195
|
text = '',
|
|
178
196
|
now = Date.now(),
|
|
179
197
|
windowMs = DUPLICATE_USER_MESSAGE_WINDOW_MS,
|
|
198
|
+
// A message the user deliberately sends again (Resend of a steer a Stop
|
|
199
|
+
// cut off) is not a duplicate of its own original, nor of an earlier Resend
|
|
200
|
+
// of it that was cancelled or failed.
|
|
201
|
+
exemptMessageIds = [],
|
|
180
202
|
} = {}) {
|
|
181
203
|
const fingerprint = normalizeDuplicateMessageText(text);
|
|
182
204
|
if (!fingerprint) return null;
|
|
183
205
|
const maxAgeMs = Math.max(60_000, Number(windowMs) || DUPLICATE_USER_MESSAGE_WINDOW_MS);
|
|
206
|
+
const exempt = new Set((Array.isArray(exemptMessageIds) ? exemptMessageIds : [])
|
|
207
|
+
.map((id) => String(id || '').trim()).filter(Boolean));
|
|
208
|
+
const isExempt = (row) => exempt.has(String(row?.id || '').trim());
|
|
184
209
|
|
|
185
210
|
for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
|
|
211
|
+
if (isExempt(row)) continue;
|
|
186
212
|
const rowText = normalizeDuplicateMessageText(row?.text || '');
|
|
187
213
|
if (!rowText || rowText !== fingerprint) continue;
|
|
188
214
|
const rowTime = Date.parse(row?.timestamp || '');
|
|
@@ -193,6 +219,7 @@ function findRecentDuplicateUserMessage({
|
|
|
193
219
|
}
|
|
194
220
|
|
|
195
221
|
for (const row of Array.isArray(recentQueueRows) ? recentQueueRows : []) {
|
|
222
|
+
if (isExempt(row)) continue;
|
|
196
223
|
const rowText = normalizeDuplicateMessageText(row?.text || '');
|
|
197
224
|
if (!rowText || rowText !== fingerprint) continue;
|
|
198
225
|
const rowTime = Date.parse(row?.timestamp || '');
|
|
@@ -1757,10 +1784,15 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1757
1784
|
sanitizeActivityText,
|
|
1758
1785
|
readSessionTranscriptMessages,
|
|
1759
1786
|
inFlightStateForConversation,
|
|
1787
|
+
// Resolves the live turn among a conversation's processing rows (mid-turn
|
|
1788
|
+
// steering can leave more than one). Falls back to the newest-processing
|
|
1789
|
+
// query for older wiring/tests that do not supply it.
|
|
1790
|
+
resolveLiveTurnQueueRow = (conversationId) => stmts.getLatestProcessingQueueByConversation.get(conversationId) || null,
|
|
1760
1791
|
emitToClientsExceptSessionId,
|
|
1761
1792
|
relayBridgeOwnerService,
|
|
1762
1793
|
relayRestartOrchestrator,
|
|
1763
1794
|
requestRelayShutdown,
|
|
1795
|
+
cancelRelayShutdown = null,
|
|
1764
1796
|
featureFlags,
|
|
1765
1797
|
sessionWorkerRegistry,
|
|
1766
1798
|
sessionWorkerSupervisor,
|
|
@@ -1778,6 +1810,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1778
1810
|
opaqueResponseRecoveryPollMs = OPAQUE_RESPONSE_RECOVERY_POLL_MS,
|
|
1779
1811
|
relayQuestionFinalizationHoldMs = RELAY_QUESTION_FINALIZATION_HOLD_MS,
|
|
1780
1812
|
imageOperationService = null,
|
|
1813
|
+
// Remote relays (server/services/remote-relay-inbound.mjs): origin on
|
|
1814
|
+
// agent-sent messages, the inbound switch, and mention unlocks. Absent in
|
|
1815
|
+
// older wiring and most tests, where every remote-relay step is a no-op.
|
|
1816
|
+
remoteRelayInbound = null,
|
|
1781
1817
|
} = deps;
|
|
1782
1818
|
|
|
1783
1819
|
const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
|
|
@@ -1929,6 +1965,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1929
1965
|
};
|
|
1930
1966
|
}
|
|
1931
1967
|
|
|
1968
|
+
// The stored preferences, for a send that must leave them alone.
|
|
1969
|
+
function readConversationModelPreference(conversationId, fallbackRelayMode = DEFAULT_RELAY_MODE) {
|
|
1970
|
+
const row = stmts.getConvAnyStatus?.get?.(String(conversationId || '').trim()) || null;
|
|
1971
|
+
return {
|
|
1972
|
+
preferredRelayMode: normalizeRelayMode(row?.preferred_relay_mode) || fallbackRelayMode || DEFAULT_RELAY_MODE,
|
|
1973
|
+
preferredModel: String(row?.preferred_model || '').trim(),
|
|
1974
|
+
preferredReasoningEffort: String(row?.preferred_reasoning_effort || '').trim(),
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1932
1978
|
function getConversationSessionState(conversationId) {
|
|
1933
1979
|
const normalizedConversationId = String(conversationId || '').trim();
|
|
1934
1980
|
if (!normalizedConversationId) {
|
|
@@ -1988,6 +2034,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1988
2034
|
markWorkerError = true,
|
|
1989
2035
|
executedProvider = null,
|
|
1990
2036
|
attemptId = null,
|
|
2037
|
+
// Extra writes that must commit only with the (fenced) failure itself.
|
|
2038
|
+
withinTransaction = null,
|
|
1991
2039
|
}) {
|
|
1992
2040
|
const now = new Date().toISOString();
|
|
1993
2041
|
const responseId = uuidv4();
|
|
@@ -2003,6 +2051,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2003
2051
|
? stmts.setFailedFenced.run(JSON.stringify(failureRecord), messageId, fencedAttemptId)
|
|
2004
2052
|
: stmts.setFailed.run(JSON.stringify(failureRecord), messageId);
|
|
2005
2053
|
if (result.changes === 0) return false;
|
|
2054
|
+
if (typeof withinTransaction === 'function') withinTransaction(now);
|
|
2006
2055
|
stmts.setQueueResponseMessageId?.run(responseId, messageId);
|
|
2007
2056
|
stmts.insertMsg.run(
|
|
2008
2057
|
responseId,
|
|
@@ -2017,6 +2066,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2017
2066
|
modelActual,
|
|
2018
2067
|
modelOrigin,
|
|
2019
2068
|
);
|
|
2069
|
+
stmts.setMessageSourceId?.run(messageId, responseId);
|
|
2020
2070
|
// Provenance travels with terminal failures too — the original hijack
|
|
2021
2071
|
// incident's signature was stolen turns dying on 402, which exited
|
|
2022
2072
|
// exactly here with no executed_provider recorded.
|
|
@@ -2054,7 +2104,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2054
2104
|
},
|
|
2055
2105
|
});
|
|
2056
2106
|
io.emit('message_status', { messageId, conversationId, status: 'failed' });
|
|
2057
|
-
void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, text: responseText });
|
|
2107
|
+
void pushDispatchService?.notifyTurnFailed?.({ conversationId, messageId: responseId, sourceMessageId: messageId, text: responseText });
|
|
2058
2108
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
2059
2109
|
const ownerSessionId = isSessionWorkerRoutingEnabled(featureFlags)
|
|
2060
2110
|
? normalizeSessionWorkerId(queueRow?.owner_sdk_session_id)
|
|
@@ -2116,6 +2166,66 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2116
2166
|
// pending — an answer may still be on its way in. A row with NO current
|
|
2117
2167
|
// attempt (finalized by a legacy responder after a requeue cleared it) makes
|
|
2118
2168
|
// every attempt-stamped card stale: the empty string matches none of them.
|
|
2169
|
+
// The name the steer-settle wording uses for the runtime that took the
|
|
2170
|
+
// prompt, from the conversation's provider type (the shared mapping the
|
|
2171
|
+
// server runtime's recovery path uses too).
|
|
2172
|
+
function steerSettleAgentLabelForConversation(conversationId) {
|
|
2173
|
+
const id = String(conversationId || '').trim();
|
|
2174
|
+
if (!id) return undefined;
|
|
2175
|
+
return steerAgentLabelForProvider(stmts.getRuntimeSessionByConversation?.get?.(id)?.provider_type);
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* Terminal failure for a row whose prompt the worker's runtime already
|
|
2180
|
+
* consumed — a folded or stopped steer whose settle never landed.
|
|
2181
|
+
* At-most-once: every path that would requeue such a row comes here instead,
|
|
2182
|
+
* since re-delivery would run the prompt a second time. `terminalError` lets
|
|
2183
|
+
* a worker supply the path-accurate wording; the default is the fold wording.
|
|
2184
|
+
*/
|
|
2185
|
+
function failConsumedQueueRow(queueRow, { reason = 'recovery', terminalError = null, attemptId = null } = {}) {
|
|
2186
|
+
const agentLabel = steerSettleAgentLabelForConversation(queueRow?.conversation_id);
|
|
2187
|
+
const failure = terminalError && typeof terminalError === 'object'
|
|
2188
|
+
? { ...buildSteerSettleFailure(queueRow, { agentLabel }), ...terminalError }
|
|
2189
|
+
: buildSteerSettleFailure(queueRow, { agentLabel });
|
|
2190
|
+
const failed = failQueueMessage({
|
|
2191
|
+
queueRow,
|
|
2192
|
+
messageId: queueRow.id,
|
|
2193
|
+
conversationId: queueRow.conversation_id,
|
|
2194
|
+
relayMode: normalizeRelayMode(queueRow.relay_mode) || DEFAULT_RELAY_MODE,
|
|
2195
|
+
model: queueRow.model || null,
|
|
2196
|
+
responseText: buildTerminalFailureTextForChat(failure),
|
|
2197
|
+
failureRecord: {
|
|
2198
|
+
...failure,
|
|
2199
|
+
kind: 'terminal',
|
|
2200
|
+
error: 'terminal-error',
|
|
2201
|
+
reason: String(reason || 'recovery'),
|
|
2202
|
+
retryCount: Number(queueRow?.retry_count || 0),
|
|
2203
|
+
},
|
|
2204
|
+
markWorkerError: false,
|
|
2205
|
+
attemptId,
|
|
2206
|
+
});
|
|
2207
|
+
if (failed) {
|
|
2208
|
+
settleRelayAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
|
|
2209
|
+
settleRelaySubagentAbortControlsForQueueMessage(queueRow.id, { ok: false, error: STEER_SETTLE_FAILED_CODE });
|
|
2210
|
+
console.warn(`[${ts()}] FAILED ${String(queueRow.id).slice(0, 8)} reason=${reason} code=${STEER_SETTLE_FAILED_STABLE_CODE} (consumed; never re-run)`);
|
|
2211
|
+
}
|
|
2212
|
+
return failed;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function normalizeConsumedSteerEntries(raw) {
|
|
2216
|
+
const seen = new Set();
|
|
2217
|
+
return (Array.isArray(raw) ? raw : [])
|
|
2218
|
+
.map((entry) => (entry && typeof entry === 'object'
|
|
2219
|
+
? { id: String(entry.id || '').trim(), attemptId: String(entry.attemptId || '').trim() || null }
|
|
2220
|
+
: { id: String(entry || '').trim(), attemptId: null }))
|
|
2221
|
+
.filter((entry) => {
|
|
2222
|
+
if (!entry.id || seen.has(entry.id)) return false;
|
|
2223
|
+
seen.add(entry.id);
|
|
2224
|
+
return true;
|
|
2225
|
+
})
|
|
2226
|
+
.slice(0, 100);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2119
2229
|
function cancelPendingRelayQuestionsForMessage(messageId) {
|
|
2120
2230
|
const targetMessageId = String(messageId || '').trim();
|
|
2121
2231
|
if (!targetMessageId || typeof stmts.cancelPendingQuestionsForStaleAttempts?.all !== 'function') return 0;
|
|
@@ -2149,7 +2259,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2149
2259
|
AND rs.sdk_session_id = ?
|
|
2150
2260
|
)
|
|
2151
2261
|
AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
|
|
2152
|
-
ORDER BY q.
|
|
2262
|
+
ORDER BY q.timestamp ASC, q.rowid ASC
|
|
2153
2263
|
LIMIT 1
|
|
2154
2264
|
`);
|
|
2155
2265
|
const transferPendingOwnerToSession = db.prepare(`
|
|
@@ -2230,6 +2340,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2230
2340
|
WHERE id = ?
|
|
2231
2341
|
AND owner_sdk_session_id = ?
|
|
2232
2342
|
AND status = 'processing'
|
|
2343
|
+
AND consumed_at IS NULL
|
|
2233
2344
|
`);
|
|
2234
2345
|
const findMostRecentConversationForSession = db.prepare(`
|
|
2235
2346
|
SELECT conversation_id
|
|
@@ -2409,15 +2520,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2409
2520
|
}
|
|
2410
2521
|
|
|
2411
2522
|
function buildRelayStopFailurePayload() {
|
|
2412
|
-
return
|
|
2413
|
-
kind: 'turn-aborted',
|
|
2414
|
-
error: 'turn-aborted',
|
|
2415
|
-
code: 'turn-aborted',
|
|
2416
|
-
stableCode: 'relay.turn-aborted',
|
|
2417
|
-
message: 'System note: This turn was stopped from the relay UI before completion.',
|
|
2418
|
-
guidance: 'Send a new message to continue when you are ready.',
|
|
2419
|
-
failedAt: new Date().toISOString(),
|
|
2420
|
-
};
|
|
2523
|
+
return buildRelayStopFailure();
|
|
2421
2524
|
}
|
|
2422
2525
|
|
|
2423
2526
|
function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
|
|
@@ -2500,6 +2603,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2500
2603
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
2501
2604
|
continue;
|
|
2502
2605
|
}
|
|
2606
|
+
if (row.consumed_at) {
|
|
2607
|
+
failConsumedQueueRow(row, { reason });
|
|
2608
|
+
continue;
|
|
2609
|
+
}
|
|
2503
2610
|
const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
|
|
2504
2611
|
const relayStreamRows = stmts.listStreamEventsByQueueMessage?.all?.(row.id);
|
|
2505
2612
|
const fallbackRelayActivities = Array.isArray(relayActivityRows)
|
|
@@ -2740,6 +2847,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2740
2847
|
});
|
|
2741
2848
|
|
|
2742
2849
|
app.post('/api/conversation/:conversationId/cancel-turn', auth, (req, res) => {
|
|
2850
|
+
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
2743
2851
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
2744
2852
|
const requestedMessageId = String(req.body?.messageId || req.body?.queueMessageId || '').trim();
|
|
2745
2853
|
const sessionState = getConversationSessionState(conversationId);
|
|
@@ -2747,7 +2855,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2747
2855
|
return rejectSessionBinding(res, sessionState.status, sessionState.error);
|
|
2748
2856
|
}
|
|
2749
2857
|
|
|
2750
|
-
|
|
2858
|
+
// The live turn, not merely the newest processing row: a steered message
|
|
2859
|
+
// folded into the running turn is also `processing`, and Stop must target
|
|
2860
|
+
// the turn actually executing (which the worker interrupts as a whole).
|
|
2861
|
+
const queueRow = resolveLiveTurnQueueRow(conversationId) || null;
|
|
2751
2862
|
if (!queueRow) {
|
|
2752
2863
|
return res.json({
|
|
2753
2864
|
ok: true,
|
|
@@ -2946,7 +3057,46 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2946
3057
|
});
|
|
2947
3058
|
});
|
|
2948
3059
|
|
|
3060
|
+
// The session worker that can still pull a processing row back out of its
|
|
3061
|
+
// runtime: the row's owner first (the worker that claimed it), then the
|
|
3062
|
+
// conversation's bound session. Only a worker whose latest heartbeat lists
|
|
3063
|
+
// the id as cancellable qualifies — the relay never guesses at the runtime's
|
|
3064
|
+
// queue state.
|
|
3065
|
+
function findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId }) {
|
|
3066
|
+
if (typeof sessionWorkerRegistry?.getWorker !== 'function') return null;
|
|
3067
|
+
const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
|
|
3068
|
+
const candidates = [...new Set([
|
|
3069
|
+
normalizeSessionWorkerId(queueRow?.owner_sdk_session_id),
|
|
3070
|
+
normalizeSessionWorkerId(conversation?.sdk_session_id),
|
|
3071
|
+
].filter(Boolean))];
|
|
3072
|
+
for (const sessionId of candidates) {
|
|
3073
|
+
const worker = sessionWorkerRegistry.getWorker(sessionId);
|
|
3074
|
+
const cancellableIds = Array.isArray(worker?.steering?.cancellableIds) ? worker.steering.cancellableIds : [];
|
|
3075
|
+
if (cancellableIds.includes(messageId)) return worker;
|
|
3076
|
+
}
|
|
3077
|
+
return null;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// Asks the owning worker to remove a pushed-but-unconsumed row from its
|
|
3081
|
+
// runtime (`cancel_pushed_message`). True when the control went out over a
|
|
3082
|
+
// live worker socket; the worker then settles the row through
|
|
3083
|
+
// POST /api/queue-cancelled. False (no such worker, id not cancellable, or
|
|
3084
|
+
// the socket is gone) leaves the row on its ordinary path.
|
|
3085
|
+
function requestPushedMessageCancel({ queueRow, conversationId, messageId }) {
|
|
3086
|
+
const worker = findWorkerHoldingCancellableMessage({ queueRow, conversationId, messageId });
|
|
3087
|
+
if (!worker) return false;
|
|
3088
|
+
const sent = sendWorkerControl?.(worker.sdkSessionId, {
|
|
3089
|
+
type: 'cancel_pushed_message',
|
|
3090
|
+
messageId,
|
|
3091
|
+
conversationId,
|
|
3092
|
+
});
|
|
3093
|
+
if (!sent) return false;
|
|
3094
|
+
console.log(`[${ts()}] UNSTEER REQ ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
|
|
3095
|
+
return true;
|
|
3096
|
+
}
|
|
3097
|
+
|
|
2949
3098
|
app.post('/api/conversation/:conversationId/cancel-queued-turn', auth, (req, res) => {
|
|
3099
|
+
if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
|
|
2950
3100
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
2951
3101
|
const requestedMessageId = String(req.body?.messageId || '').trim();
|
|
2952
3102
|
if (!conversationId) {
|
|
@@ -2979,6 +3129,19 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2979
3129
|
|
|
2980
3130
|
const currentStatus = String(queueRow.status || '').trim().toLowerCase();
|
|
2981
3131
|
if (currentStatus === 'processing') {
|
|
3132
|
+
// Un-steer: a row the owning worker pushed into its runtime but reports
|
|
3133
|
+
// as still unconsumed (heartbeat `steering.cancellableIds`) can be pulled
|
|
3134
|
+
// back out. The worker answers through POST /api/queue-cancelled once the
|
|
3135
|
+
// runtime confirms the removal; until then the row stays processing.
|
|
3136
|
+
if (requestPushedMessageCancel({ queueRow, conversationId, messageId: requestedMessageId })) {
|
|
3137
|
+
return res.json({
|
|
3138
|
+
ok: true,
|
|
3139
|
+
cancelled: false,
|
|
3140
|
+
acknowledgement: 'cancel-requested',
|
|
3141
|
+
requestedMessageId,
|
|
3142
|
+
status: currentStatus,
|
|
3143
|
+
});
|
|
3144
|
+
}
|
|
2982
3145
|
return res.json({
|
|
2983
3146
|
ok: true,
|
|
2984
3147
|
cancelled: false,
|
|
@@ -3851,6 +4014,52 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3851
4014
|
});
|
|
3852
4015
|
});
|
|
3853
4016
|
|
|
4017
|
+
// A Resend of a steer a Stop cut off unanswered. Honored only for a user
|
|
4018
|
+
// message of this conversation whose own settle marker is kind='stopped' and
|
|
4019
|
+
// whose text is what is being sent; anything else is an ordinary send (and
|
|
4020
|
+
// gets the ordinary duplicate guard). Returns { originId } to honor it,
|
|
4021
|
+
// { alreadyResentAs } when an earlier Resend of it still stands (from any
|
|
4022
|
+
// device, at any time), or null.
|
|
4023
|
+
function resolveStoppedSteerResend(conversationId, rawMessageId, text) {
|
|
4024
|
+
const messageId = String(rawMessageId || '').trim();
|
|
4025
|
+
if (!messageId || !conversationId) return null;
|
|
4026
|
+
try {
|
|
4027
|
+
const original = db.prepare(`
|
|
4028
|
+
SELECT id, text, mode FROM messages WHERE id = ? AND conversation_id = ? AND role = 'user'
|
|
4029
|
+
`).get(messageId, conversationId);
|
|
4030
|
+
if (!original) return null;
|
|
4031
|
+
// Stored user text may carry the relay's prompt context; the incoming
|
|
4032
|
+
// text is already stripped, so compare like with like.
|
|
4033
|
+
const originalText = stripRelayPromptContext(original.text, original.mode);
|
|
4034
|
+
if (normalizeDuplicateMessageText(originalText) !== normalizeDuplicateMessageText(text)) return null;
|
|
4035
|
+
const stopped = db.prepare(`
|
|
4036
|
+
SELECT 1 FROM messages
|
|
4037
|
+
WHERE conversation_id = ? AND role = 'assistant' AND kind = 'stopped'
|
|
4038
|
+
AND (source_message_id = ? OR id IN (SELECT response_message_id FROM queue WHERE id = ?))
|
|
4039
|
+
LIMIT 1
|
|
4040
|
+
`).get(conversationId, messageId, messageId);
|
|
4041
|
+
if (!stopped) return null;
|
|
4042
|
+
const earlier = db.prepare(`
|
|
4043
|
+
SELECT m.id,
|
|
4044
|
+
q.id AS queue_id,
|
|
4045
|
+
q.status AS queue_status,
|
|
4046
|
+
EXISTS (SELECT 1 FROM messages a WHERE a.role = 'assistant' AND a.source_message_id = m.id) AS answered
|
|
4047
|
+
FROM messages m
|
|
4048
|
+
LEFT JOIN queue q ON q.id = m.id
|
|
4049
|
+
WHERE m.conversation_id = ? AND m.role = 'user' AND m.resend_of_message_id = ?
|
|
4050
|
+
`).all(conversationId, messageId);
|
|
4051
|
+
const standing = earlier.find((row) => isLiveResend({
|
|
4052
|
+
hasQueueRow: !!row.queue_id,
|
|
4053
|
+
queueStatus: row.queue_status,
|
|
4054
|
+
answered: !!row.answered,
|
|
4055
|
+
}));
|
|
4056
|
+
if (standing) return { alreadyResentAs: standing.id };
|
|
4057
|
+
return { originId: messageId, exemptIds: [messageId, ...earlier.map((row) => row.id)] };
|
|
4058
|
+
} catch {
|
|
4059
|
+
return null;
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
|
|
3854
4063
|
// POST /api/message — browser sends a message
|
|
3855
4064
|
app.post('/api/message', auth, async (req, res) => {
|
|
3856
4065
|
const {
|
|
@@ -3869,6 +4078,12 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3869
4078
|
attachments: rawAttachments,
|
|
3870
4079
|
imageTarget: rawImageTarget,
|
|
3871
4080
|
} = req.body;
|
|
4081
|
+
// A prompt from another relay's agent (body `origin` or the
|
|
4082
|
+
// X-OAR-Remote-Origin header) is refused while the inbound switch is off.
|
|
4083
|
+
// Otherwise its provenance is stored with the message, and it never
|
|
4084
|
+
// unlocks relays or touches the composer draft and preferences.
|
|
4085
|
+
const remoteRequest = admitRemoteRelayRequest(req, res, remoteRelayInbound);
|
|
4086
|
+
if (!remoteRequest) return;
|
|
3872
4087
|
const sessionId = clientId || ensureSessionId(req, res);
|
|
3873
4088
|
const requesterIdentity = readBridgeIdentity(req);
|
|
3874
4089
|
const requesterSessionId = normalizeSessionWorkerId(requesterIdentity?.sessionId);
|
|
@@ -3877,6 +4092,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3877
4092
|
const requestedRelayMode = normalizeRelayMode(relayMode || mode);
|
|
3878
4093
|
const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
|
|
3879
4094
|
const normalizedAttachments = normalizeAttachments(rawAttachments);
|
|
4095
|
+
const requestedAttachmentCount = Math.min(
|
|
4096
|
+
MAX_UPLOAD_ATTACHMENTS,
|
|
4097
|
+
Array.isArray(rawAttachments) ? rawAttachments.filter((item) => item && typeof item === 'object').length : (rawAttachments ? 1 : 0),
|
|
4098
|
+
);
|
|
3880
4099
|
const referenceResolution = collectReferenceAttachmentsFromText(trimmedText);
|
|
3881
4100
|
const attachments = mergeMessageAttachments(normalizedAttachments, referenceResolution.attachments);
|
|
3882
4101
|
const imageTarget = rawImageTarget && typeof rawImageTarget === 'object'
|
|
@@ -4346,7 +4565,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4346
4565
|
}
|
|
4347
4566
|
}
|
|
4348
4567
|
|
|
4349
|
-
|
|
4568
|
+
const resend = shouldCreateConversation
|
|
4569
|
+
? null
|
|
4570
|
+
: resolveStoppedSteerResend(conversationId, req.body.resendOfMessageId, trimmedText);
|
|
4571
|
+
if (resend?.alreadyResentAs) {
|
|
4572
|
+
return res.json({
|
|
4573
|
+
ok: true,
|
|
4574
|
+
duplicate: true,
|
|
4575
|
+
alreadyResent: true,
|
|
4576
|
+
duplicateOfMessageId: resend.alreadyResentAs,
|
|
4577
|
+
conversationId,
|
|
4578
|
+
});
|
|
4579
|
+
}
|
|
4580
|
+
const resendOriginId = resend?.originId || null;
|
|
4581
|
+
// Another relay's agent sends every prompt with a fresh messageId, and a
|
|
4582
|
+
// retry of the same send hits the messageId check at insert. The text
|
|
4583
|
+
// window below would instead swallow a deliberate repeat ("run the tests
|
|
4584
|
+
// again"), which the agent cannot tell from a lost send.
|
|
4585
|
+
const exemptFromTextDuplicateCheck = remoteRequest.remote && !!clientMessageId;
|
|
4586
|
+
if (!shouldCreateConversation && !exemptFromTextDuplicateCheck) {
|
|
4350
4587
|
const recentUserMessages = stmts.getRecentMessagesDesc.all(conversationId, 20);
|
|
4351
4588
|
const recentQueueRows = db.prepare(`
|
|
4352
4589
|
SELECT id, text, status, timestamp
|
|
@@ -4359,6 +4596,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4359
4596
|
recentUserMessages,
|
|
4360
4597
|
recentQueueRows,
|
|
4361
4598
|
text: trimmedText,
|
|
4599
|
+
exemptMessageIds: resend?.exemptIds || [],
|
|
4362
4600
|
});
|
|
4363
4601
|
if (duplicateMessage) {
|
|
4364
4602
|
return res.json({
|
|
@@ -4375,7 +4613,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4375
4613
|
|
|
4376
4614
|
const convId = shouldCreateConversation ? uuidv4() : conversationId;
|
|
4377
4615
|
if (shouldCreateConversation) {
|
|
4378
|
-
|
|
4616
|
+
// An agent's prompt opens with the sending relay's header line, which
|
|
4617
|
+
// says who is talking, not what the conversation is about.
|
|
4618
|
+
const titleText = remoteRequest.remote ? stripRemotePromptHeader(trimmedText).trim() : trimmedText;
|
|
4619
|
+
getOrCreateConversation(convId, titleText || attachmentSummary(attachments) || 'Image');
|
|
4379
4620
|
}
|
|
4380
4621
|
let conversationWorkspaceRootState = null;
|
|
4381
4622
|
if (workspaceRootUpdate.changed && workspaceRootUpdate.resolvedPath) {
|
|
@@ -4435,7 +4676,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4435
4676
|
isNewConversation: shouldCreateConversation,
|
|
4436
4677
|
});
|
|
4437
4678
|
const msgId = clientMessageId || uuidv4();
|
|
4438
|
-
const
|
|
4679
|
+
const promptText = shouldApplySeed
|
|
4439
4680
|
? [
|
|
4440
4681
|
'[Carry-over context from previous compacted conversation]',
|
|
4441
4682
|
String(convSeed.summary_seed).trim(),
|
|
@@ -4444,6 +4685,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4444
4685
|
trimmedText || '(User sent image attachments only.)',
|
|
4445
4686
|
].join('\n')
|
|
4446
4687
|
: trimmedText;
|
|
4688
|
+
// Only what a human typed unlocks a remote relay (decision 19). The hint
|
|
4689
|
+
// rides in the queued prompt only, so the stored message and the user's
|
|
4690
|
+
// bubble stay exactly what was typed.
|
|
4691
|
+
const mentionedRemoteRelays = remoteRequest.remote
|
|
4692
|
+
? []
|
|
4693
|
+
: findMentionedRemoteRelays(remoteRelayInbound, trimmedText);
|
|
4694
|
+
const queueText = withRemoteRelayMentionHint(promptText, formatRemoteRelayMentionHint(mentionedRemoteRelays));
|
|
4695
|
+
// A Resend never came from the composer, and neither did another relay's
|
|
4696
|
+
// agent: whatever the user is typing there stays their draft.
|
|
4697
|
+
const keepsComposerDraft = !!resendOriginId || remoteRequest.remote;
|
|
4447
4698
|
|
|
4448
4699
|
const requestedModelOrigin = deriveModelOrigin(requestedModelVariantId || requestedModel);
|
|
4449
4700
|
let conversationPreferences;
|
|
@@ -4462,29 +4713,33 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4462
4713
|
null,
|
|
4463
4714
|
requestedModelOrigin,
|
|
4464
4715
|
);
|
|
4716
|
+
if (remoteRequest.origin) remoteRelayInbound?.repository?.setMessageOrigin?.(msgId, remoteRequest.origin);
|
|
4465
4717
|
linkUploadReferences(convId, msgId, attachments);
|
|
4466
4718
|
stmts.updateConvTime.run(now, convId);
|
|
4467
|
-
if (
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4719
|
+
if (resendOriginId) stmts.setMessageResendOf?.run(resendOriginId, msgId);
|
|
4720
|
+
if (!keepsComposerDraft) {
|
|
4721
|
+
if (typeof stmts.updateConvDraft?.run === 'function') {
|
|
4722
|
+
stmts.updateConvDraft.run(null, now, sessionId || null, convId);
|
|
4723
|
+
} else {
|
|
4724
|
+
db.prepare(`
|
|
4725
|
+
UPDATE conversations
|
|
4726
|
+
SET draft_text = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
|
|
4727
|
+
WHERE id = ?
|
|
4728
|
+
`).run(now, sessionId || null, convId);
|
|
4729
|
+
}
|
|
4730
|
+
// The message now owns these blobs, so the draft placeholders can go. The
|
|
4731
|
+
// sent-message references were just inserted above, so nothing is orphaned.
|
|
4732
|
+
if (typeof stmts.updateConvDraftAttachments?.run === 'function') {
|
|
4733
|
+
stmts.updateConvDraftAttachments.run(null, now, sessionId || null, convId);
|
|
4734
|
+
} else {
|
|
4735
|
+
db.prepare(`
|
|
4736
|
+
UPDATE conversations
|
|
4737
|
+
SET draft_attachments = NULL, draft_updated_at = ?, draft_updated_by_client_id = ?
|
|
4738
|
+
WHERE id = ?
|
|
4739
|
+
`).run(now, sessionId || null, convId);
|
|
4740
|
+
}
|
|
4741
|
+
stmts.deleteDraftUploadRefs?.run?.(convId);
|
|
4486
4742
|
}
|
|
4487
|
-
stmts.deleteDraftUploadRefs?.run?.(convId);
|
|
4488
4743
|
// Cursor allows per-turn model switching, so the pinned provider_model has
|
|
4489
4744
|
// to follow the accepted model; otherwise the conversation list and the
|
|
4490
4745
|
// composer keep restoring the model chosen at bootstrap.
|
|
@@ -4509,13 +4764,17 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4509
4764
|
) {
|
|
4510
4765
|
stmts.updateRuntimeSessionProvider.run('claude', claudeModel, claudeModel, now, runtimeSession.id);
|
|
4511
4766
|
}
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4767
|
+
// Another relay's agent picks the model and mode of its own turn; the
|
|
4768
|
+
// composer keeps restoring the user's choice for this conversation.
|
|
4769
|
+
conversationPreferences = remoteRequest.remote && !shouldCreateConversation
|
|
4770
|
+
? readConversationModelPreference(convId, requestedRelayMode)
|
|
4771
|
+
: persistConversationModelPreference(
|
|
4772
|
+
convId,
|
|
4773
|
+
requestedRelayMode,
|
|
4774
|
+
requestedModel,
|
|
4775
|
+
requestedReasoningEffort,
|
|
4776
|
+
now,
|
|
4777
|
+
);
|
|
4519
4778
|
stmts.insertQ.run(
|
|
4520
4779
|
msgId,
|
|
4521
4780
|
convId,
|
|
@@ -4574,14 +4833,21 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4574
4833
|
}
|
|
4575
4834
|
throw error;
|
|
4576
4835
|
}
|
|
4577
|
-
|
|
4836
|
+
recordRemoteRelayUnlocks(remoteRelayInbound, {
|
|
4578
4837
|
conversationId: convId,
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
draftUpdatedAt: now,
|
|
4582
|
-
draftUpdatedByClientId: sessionId || null,
|
|
4583
|
-
senderClientId: sessionId || null,
|
|
4838
|
+
messageId: msgId,
|
|
4839
|
+
relays: mentionedRemoteRelays,
|
|
4584
4840
|
});
|
|
4841
|
+
if (!keepsComposerDraft) {
|
|
4842
|
+
io.emit('conversation_draft_updated', {
|
|
4843
|
+
conversationId: convId,
|
|
4844
|
+
draftText: '',
|
|
4845
|
+
draftAttachments: [],
|
|
4846
|
+
draftUpdatedAt: now,
|
|
4847
|
+
draftUpdatedByClientId: sessionId || null,
|
|
4848
|
+
senderClientId: sessionId || null,
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4585
4851
|
if (ownerSessionId) {
|
|
4586
4852
|
const existingWorker = sessionWorkerRegistry?.getWorker?.(ownerSessionId) || null;
|
|
4587
4853
|
sessionWorkerRegistry?.upsertWorker?.({
|
|
@@ -4642,7 +4908,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4642
4908
|
stmts.clearConvSeed.run(now, convId);
|
|
4643
4909
|
}
|
|
4644
4910
|
|
|
4645
|
-
console.log(`[${ts()}] QUEUED ${msgId.slice(0,8)} conv=${convId.slice(0,8)} rs=${String(runtimeSession?.id || 'none').slice(0,8)} owner=${String(ownerSessionId || 'none').slice(0,8)} new=${!conversationId || !!newConversation} model=${requestedModel} variant=${requestedModelVariantId}${requestedReasoningEffort ? ` effort=${requestedReasoningEffort}` : ''} mode=${requestedRelayMode} text="${trimmedText.slice(0,60)}"${shouldApplySeed ? ' seeded=1' : ''}${attachments.length ? ` attachments=${attachments.length}` : ''}`);
|
|
4911
|
+
console.log(`[${ts()}] QUEUED ${msgId.slice(0,8)} conv=${convId.slice(0,8)} rs=${String(runtimeSession?.id || 'none').slice(0,8)} owner=${String(ownerSessionId || 'none').slice(0,8)} new=${!conversationId || !!newConversation} model=${requestedModel} variant=${requestedModelVariantId}${requestedReasoningEffort ? ` effort=${requestedReasoningEffort}` : ''} mode=${requestedRelayMode} text="${trimmedText.slice(0,60)}"${shouldApplySeed ? ' seeded=1' : ''}${attachments.length ? ` attachments=${attachments.length}` : ''}${remoteRequest.remote ? ` remote=${remoteRequest.origin?.relayName || remoteRequest.origin?.relayId || 'unknown'} hops=${remoteRequest.origin?.hops ?? '?'}` : ''}${mentionedRemoteRelays.length ? ` mentions=${mentionedRemoteRelays.length}` : ''}`);
|
|
4646
4912
|
|
|
4647
4913
|
emitToClientsExceptSessionId(
|
|
4648
4914
|
'user_message',
|
|
@@ -4658,6 +4924,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4658
4924
|
mode: requestedRelayMode,
|
|
4659
4925
|
timestamp: now,
|
|
4660
4926
|
attachments,
|
|
4927
|
+
...(remoteRequest.origin ? { origin: remoteRequest.origin } : {}),
|
|
4661
4928
|
},
|
|
4662
4929
|
},
|
|
4663
4930
|
sessionId,
|
|
@@ -4697,6 +4964,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4697
4964
|
...workspaceRootPayload(),
|
|
4698
4965
|
referenceAttachmentCount: referenceResolution.attachments.length,
|
|
4699
4966
|
skippedReferenceAttachments: referenceResolution.skipped,
|
|
4967
|
+
// Attachment references that no longer resolve (a deleted upload) are
|
|
4968
|
+
// dropped silently by normalizeAttachments; the sender is told how many.
|
|
4969
|
+
droppedAttachmentCount: Math.max(0, requestedAttachmentCount - normalizedAttachments.length),
|
|
4700
4970
|
imageOperation: imageOperation
|
|
4701
4971
|
? {
|
|
4702
4972
|
id: imageOperation.id,
|
|
@@ -4711,6 +4981,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4711
4981
|
touchCli();
|
|
4712
4982
|
const requester = readBridgeIdentity(req);
|
|
4713
4983
|
const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
|
|
4984
|
+
const settleFailedHandled = [];
|
|
4985
|
+
const settleFailedSkipped = [];
|
|
4714
4986
|
const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
|
|
4715
4987
|
// A persistent-process worker can hold several live rows at once (the
|
|
4716
4988
|
// running turn plus a delivered message queued behind it plus a
|
|
@@ -4746,7 +5018,71 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4746
5018
|
pid: normalizedRequesterPid,
|
|
4747
5019
|
});
|
|
4748
5020
|
}
|
|
5021
|
+
// The worker's composer-facing steering snapshot rides the heartbeat;
|
|
5022
|
+
// store it on the registry entry (status payload spreads it to clients)
|
|
5023
|
+
// only when it actually changed, so quiet heartbeats stay write-free.
|
|
5024
|
+
// Normalized with the registry's own rules BEFORE comparing — comparing
|
|
5025
|
+
// raw against stored would never converge for values the registry drops.
|
|
5026
|
+
const steering = normalizeWorkerSteeringSnapshot(req.body?.steering);
|
|
5027
|
+
if (steering) {
|
|
5028
|
+
const workerForSteering = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
|
|
5029
|
+
const stored = workerForSteering?.steering || null;
|
|
5030
|
+
const storedCancellableIds = Array.isArray(stored?.cancellableIds) ? stored.cancellableIds : [];
|
|
5031
|
+
const changed = !stored
|
|
5032
|
+
|| stored.turnActive !== steering.turnActive
|
|
5033
|
+
|| stored.canSteer !== steering.canSteer
|
|
5034
|
+
|| (stored.holdReason || null) !== (steering.holdReason || null)
|
|
5035
|
+
|| (stored.messageId || null) !== (steering.messageId || null)
|
|
5036
|
+
// The Copilot SDK worker's opt-in and its un-steerable set: a flip
|
|
5037
|
+
// in either must reach the status payload (composer gate, bubble
|
|
5038
|
+
// Cancel), so it counts as a change like the hold fields do.
|
|
5039
|
+
|| (stored.supported === true) !== (steering.supported === true)
|
|
5040
|
+
|| storedCancellableIds.join('\n') !== steering.cancellableIds.join('\n');
|
|
5041
|
+
if (workerForSteering && changed) {
|
|
5042
|
+
sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
|
|
5043
|
+
// Pushed rather than left for the clients' status poll: the
|
|
5044
|
+
// composer's Steer/Queue label and the pushed rows' Cancel read it,
|
|
5045
|
+
// and the poll would add up to 4 s to a heartbeat that already lags.
|
|
5046
|
+
io.emit('session_worker_steering', { sdkSessionId: requesterSessionId, steering });
|
|
5047
|
+
}
|
|
5048
|
+
}
|
|
4749
5049
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
5050
|
+
// Consumed steers whose settle the worker could not save within its
|
|
5051
|
+
// retry budget (e.g. its writes keep failing): the relay fails them
|
|
5052
|
+
// itself with the worker's terminal error, never requeueing them.
|
|
5053
|
+
const settleFailedEntries = Array.isArray(req.body?.settleFailed) ? req.body.settleFailed.slice(0, 50) : [];
|
|
5054
|
+
const requesterConversationForSettle = String(requester?.conversationId || '').trim() || null;
|
|
5055
|
+
for (const entry of settleFailedEntries) {
|
|
5056
|
+
const id = String(entry?.id || '').trim();
|
|
5057
|
+
const row = id ? stmts.findQById.get(id) : null;
|
|
5058
|
+
if (!row || row.status !== 'processing') {
|
|
5059
|
+
if (id) settleFailedHandled.push(id);
|
|
5060
|
+
continue;
|
|
5061
|
+
}
|
|
5062
|
+
// Only the worker that owns the row may have it failed, and only in a
|
|
5063
|
+
// conversation that worker serves.
|
|
5064
|
+
// Anything else is acknowledged as skipped: the worker has no claim on
|
|
5065
|
+
// the row, so it stops reporting it (and releases its hold), and the
|
|
5066
|
+
// row takes the ordinary recovery path — where a consumed mark still
|
|
5067
|
+
// fails it rather than re-running it.
|
|
5068
|
+
const rowSession = normalizeSessionWorkerId(stmts.getRuntimeSessionByConversation?.get(row.conversation_id)?.sdk_session_id);
|
|
5069
|
+
if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId
|
|
5070
|
+
|| (rowSession !== requesterSessionId && String(row.conversation_id) !== String(requesterConversationForSettle || ''))) {
|
|
5071
|
+
settleFailedSkipped.push(id);
|
|
5072
|
+
continue;
|
|
5073
|
+
}
|
|
5074
|
+
const attemptId = String(entry?.attemptId || '').trim() || null;
|
|
5075
|
+
if (attemptId && String(row.attempt_id || '') !== attemptId) {
|
|
5076
|
+
settleFailedHandled.push(id);
|
|
5077
|
+
continue;
|
|
5078
|
+
}
|
|
5079
|
+
failConsumedQueueRow(row, {
|
|
5080
|
+
reason: 'settle-failed',
|
|
5081
|
+
terminalError: entry?.terminalError && typeof entry.terminalError === 'object' ? entry.terminalError : null,
|
|
5082
|
+
attemptId,
|
|
5083
|
+
});
|
|
5084
|
+
settleFailedHandled.push(id);
|
|
5085
|
+
}
|
|
4750
5086
|
if (activeQueueMessageIds.length) {
|
|
4751
5087
|
const now = new Date().toISOString();
|
|
4752
5088
|
const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
|
|
@@ -4765,7 +5101,91 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4765
5101
|
}
|
|
4766
5102
|
relayBridgeOwnerService?.observe?.(requester);
|
|
4767
5103
|
const { pendingCount } = queueCounts();
|
|
4768
|
-
res.json({
|
|
5104
|
+
res.json({
|
|
5105
|
+
ok: true,
|
|
5106
|
+
pendingCount,
|
|
5107
|
+
...(settleFailedHandled.length ? { settleFailedHandled } : {}),
|
|
5108
|
+
...(settleFailedSkipped.length ? { settleFailedSkipped } : {}),
|
|
5109
|
+
});
|
|
5110
|
+
});
|
|
5111
|
+
|
|
5112
|
+
// POST /api/queue-consumed — the Claude worker marks rows whose prompt the
|
|
5113
|
+
// CLI has taken when no response carries the mark (a turn the user stopped,
|
|
5114
|
+
// a turn handed off mid-reply). Consumed rows are at-most-once: recovery
|
|
5115
|
+
// fails them terminally instead of re-running them.
|
|
5116
|
+
app.post('/api/queue-consumed', auth, (req, res) => {
|
|
5117
|
+
touchCli();
|
|
5118
|
+
const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
|
|
5119
|
+
if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
|
|
5120
|
+
const conversationId = String(req.body?.conversationId || '').trim();
|
|
5121
|
+
const entries = normalizeConsumedSteerEntries(req.body?.entries);
|
|
5122
|
+
if (!conversationId || !entries.length) return res.status(400).json({ error: 'Missing conversationId or entries' });
|
|
5123
|
+
// `consumed: false` un-marks a steer the CLI then replayed as a turn of
|
|
5124
|
+
// its own. Either way only the owning worker, on the row's current
|
|
5125
|
+
// attempt, may touch the mark.
|
|
5126
|
+
const statement = req.body?.consumed === false ? stmts.clearQueueConsumed : stmts.markQueueConsumed;
|
|
5127
|
+
const at = new Date().toISOString();
|
|
5128
|
+
let changed = 0;
|
|
5129
|
+
for (const entry of entries) {
|
|
5130
|
+
changed += Number(statement?.run({
|
|
5131
|
+
at,
|
|
5132
|
+
id: entry.id,
|
|
5133
|
+
conversationId,
|
|
5134
|
+
owner: requesterSessionId,
|
|
5135
|
+
attemptId: entry.attemptId,
|
|
5136
|
+
})?.changes || 0);
|
|
5137
|
+
}
|
|
5138
|
+
res.json({ ok: true, changed });
|
|
5139
|
+
});
|
|
5140
|
+
|
|
5141
|
+
// POST /api/queue-cancelled — the owning worker reports that a pushed but
|
|
5142
|
+
// still unconsumed row was removed from its runtime (an un-steer the user
|
|
5143
|
+
// asked for through cancel-queued-turn → `cancel_pushed_message`). The row
|
|
5144
|
+
// ends exactly like a pending cancel: deleted, its open cards cancelled, no
|
|
5145
|
+
// assistant row, `message_status: cancelled`. Fenced like /api/queue-consumed:
|
|
5146
|
+
// only the row's owner, on the row's current attempt, may settle it.
|
|
5147
|
+
app.post('/api/queue-cancelled', auth, (req, res) => {
|
|
5148
|
+
touchCli();
|
|
5149
|
+
const requesterSessionId = normalizeSessionWorkerId(readBridgeIdentity(req)?.sessionId);
|
|
5150
|
+
if (!requesterSessionId) return res.status(403).json({ error: 'Worker identity required' });
|
|
5151
|
+
const conversationId = String(req.body?.conversationId || '').trim();
|
|
5152
|
+
const messageId = String(req.body?.messageId || '').trim();
|
|
5153
|
+
const attemptId = String(req.body?.attemptId || '').trim() || null;
|
|
5154
|
+
if (!conversationId || !messageId) return res.status(400).json({ error: 'Missing conversationId or messageId' });
|
|
5155
|
+
|
|
5156
|
+
const row = stmts.findQById.get(messageId) || null;
|
|
5157
|
+
if (!row) return res.status(404).json({ error: 'not-found' });
|
|
5158
|
+
if (String(row.conversation_id || '').trim() !== conversationId) {
|
|
5159
|
+
return res.status(409).json({ error: 'conversation-mismatch' });
|
|
5160
|
+
}
|
|
5161
|
+
const status = String(row.status || '').trim().toLowerCase();
|
|
5162
|
+
if (status !== 'processing') return res.status(409).json({ error: 'not-processing', status });
|
|
5163
|
+
if (normalizeSessionWorkerId(row.owner_sdk_session_id) !== requesterSessionId) {
|
|
5164
|
+
return res.status(409).json({ error: 'not-owned' });
|
|
5165
|
+
}
|
|
5166
|
+
const rowAttemptId = String(row.attempt_id || '').trim() || null;
|
|
5167
|
+
if (attemptId && rowAttemptId && attemptId !== rowAttemptId) {
|
|
5168
|
+
console.warn(`[${ts()}] STALE ATTEMPT queue-cancelled ${messageId.slice(0, 8)} attempt=${attemptId.slice(0, 8)} current=${rowAttemptId.slice(0, 8)}`);
|
|
5169
|
+
return res.status(409).json({ error: 'stale_attempt' });
|
|
5170
|
+
}
|
|
5171
|
+
|
|
5172
|
+
// Same transaction shape as the pending cancel above: delete first, so the
|
|
5173
|
+
// question cancel sees no current attempt and flips every pending card of
|
|
5174
|
+
// the row (with foreign keys on, the cascade has already taken them).
|
|
5175
|
+
const cancelPushedTurn = db.transaction(() => {
|
|
5176
|
+
stmts.deleteQueueById.run(messageId);
|
|
5177
|
+
cancelPendingRelayQuestionsForMessage(messageId);
|
|
5178
|
+
});
|
|
5179
|
+
cancelPushedTurn();
|
|
5180
|
+
// A processing row may carry open Stop controls (the live-turn picker can
|
|
5181
|
+
// land on it before it has output); they fail like on a requeue so no
|
|
5182
|
+
// caller waits on a row that no longer exists.
|
|
5183
|
+
settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
|
|
5184
|
+
settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-cancelled' });
|
|
5185
|
+
|
|
5186
|
+
io.emit('message_status', { messageId, conversationId, status: 'cancelled' });
|
|
5187
|
+
console.log(`[${ts()}] UNSTEERED ${messageId.slice(0, 8)} conv=${conversationId.slice(0, 8)}`);
|
|
5188
|
+
res.json({ ok: true, cancelled: true });
|
|
4769
5189
|
});
|
|
4770
5190
|
|
|
4771
5191
|
// POST /api/grok-native-session — Grok worker persists the ACP session id so
|
|
@@ -5685,6 +6105,20 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
5685
6105
|
return res.json({ ok: true, ...result });
|
|
5686
6106
|
});
|
|
5687
6107
|
|
|
6108
|
+
// Withdraw a queued (not yet started) shutdown/restart. Same trust boundary
|
|
6109
|
+
// as queueing one.
|
|
6110
|
+
app.post('/api/relay/shutdown/cancel', auth, (req, res) => {
|
|
6111
|
+
if (!isLoopbackRequest(req)) {
|
|
6112
|
+
return res.status(403).json({ error: 'Relay shutdown endpoint is localhost-only' });
|
|
6113
|
+
}
|
|
6114
|
+
if (typeof cancelRelayShutdown !== 'function') {
|
|
6115
|
+
return res.status(501).json({ error: 'Relay shutdown orchestration is unavailable' });
|
|
6116
|
+
}
|
|
6117
|
+
const requestedBy = String(req.body?.requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
|
|
6118
|
+
const result = cancelRelayShutdown({ requestedBy });
|
|
6119
|
+
return res.json({ ok: true, ...result });
|
|
6120
|
+
});
|
|
6121
|
+
|
|
5688
6122
|
app.post('/api/image-operations/:operationId/execute', auth, async (req, res) => {
|
|
5689
6123
|
if (!isLoopbackRequest(req)) {
|
|
5690
6124
|
return res.status(403).json({ error: 'Image operation execution is localhost-only' });
|
|
@@ -6054,10 +6488,49 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6054
6488
|
});
|
|
6055
6489
|
});
|
|
6056
6490
|
|
|
6491
|
+
const SETTLED_STEER_KINDS = new Set(['folded', 'stopped']);
|
|
6492
|
+
|
|
6057
6493
|
// POST /api/response — CLI submits response
|
|
6058
6494
|
app.post('/api/response', auth, async (req, res) => {
|
|
6059
6495
|
touchCli();
|
|
6060
6496
|
const { messageId, conversationId, text, model, mode, generatedImages: rawGeneratedImages } = req.body;
|
|
6497
|
+
// A steering absorption (Claude worker): this reply ends where a message
|
|
6498
|
+
// pushed mid-turn was picked up, and the turn continues on that message's
|
|
6499
|
+
// own row. Stamped as kind='absorbed' so the transcript renders the two
|
|
6500
|
+
// turns as one merged flow.
|
|
6501
|
+
// A worker not yet respawned onto 0.9.3 still stamps its fold stubs
|
|
6502
|
+
// absorbed:true; the stub text identifies them, and 'absorbed' on a fold
|
|
6503
|
+
// would mis-merge the next ordinary message.
|
|
6504
|
+
const staleFoldStub = req.body.absorbed === true && String(text || '').trim() === STEER_FOLDED_TEXT;
|
|
6505
|
+
const absorbed = req.body.absorbed === true && !staleFoldStub;
|
|
6506
|
+
// The other settle stubs of a consumed steer (never re-run — the CLI saw
|
|
6507
|
+
// it): 'folded' was answered by the turn it was steered into and renders
|
|
6508
|
+
// as a marker on its own message; 'stopped' was steered into a turn the
|
|
6509
|
+
// user then stopped, so it went unanswered and the client offers Resend.
|
|
6510
|
+
const requestedKind = staleFoldStub ? 'folded' : String(req.body.kind || '').trim();
|
|
6511
|
+
const settledKind = absorbed
|
|
6512
|
+
? 'absorbed'
|
|
6513
|
+
: (SETTLED_STEER_KINDS.has(requestedKind) ? requestedKind : null);
|
|
6514
|
+
// Steers pushed into the turn this response finishes and not yet settled:
|
|
6515
|
+
// the CLI has taken their prompts, so they are marked consumed together
|
|
6516
|
+
// with this commit and no recovery path may ever re-run them.
|
|
6517
|
+
const consumedSteerEntries = normalizeConsumedSteerEntries(req.body.consumedSteerIds)
|
|
6518
|
+
.filter((entry) => entry.id !== String(messageId || '').trim());
|
|
6519
|
+
// Runs inside the response's fenced write (both the success and the
|
|
6520
|
+
// terminal-failure transaction), so a superseded attempt's late publish
|
|
6521
|
+
// can never mark rows; the steers must share the finishing row's owner.
|
|
6522
|
+
const markSteersConsumed = (conversationIdForMark, atIso) => {
|
|
6523
|
+
const owner = normalizeSessionWorkerId(q?.owner_sdk_session_id);
|
|
6524
|
+
for (const entry of consumedSteerEntries) {
|
|
6525
|
+
stmts.markQueueConsumed?.run({
|
|
6526
|
+
at: atIso,
|
|
6527
|
+
id: entry.id,
|
|
6528
|
+
conversationId: conversationIdForMark,
|
|
6529
|
+
owner,
|
|
6530
|
+
attemptId: entry.attemptId,
|
|
6531
|
+
});
|
|
6532
|
+
}
|
|
6533
|
+
};
|
|
6061
6534
|
const trimmedText = String(text || '').trim();
|
|
6062
6535
|
const terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
|
|
6063
6536
|
// Final digests of background workflows that settled during this turn —
|
|
@@ -6159,6 +6632,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6159
6632
|
failedAt: new Date().toISOString(),
|
|
6160
6633
|
},
|
|
6161
6634
|
attemptId: claimedAttemptId,
|
|
6635
|
+
// An errored turn still took the steers pushed into it.
|
|
6636
|
+
withinTransaction: (atIso) => markSteersConsumed(targetConversationId, atIso),
|
|
6162
6637
|
});
|
|
6163
6638
|
if (!failed) {
|
|
6164
6639
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
@@ -6266,6 +6741,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6266
6741
|
? stmts.setDoneFenced.run(resolvedText, messageId, claimedAttemptId)
|
|
6267
6742
|
: stmts.setDone.run(resolvedText, messageId);
|
|
6268
6743
|
if (result.changes === 0) return false;
|
|
6744
|
+
markSteersConsumed(targetConversationId, now);
|
|
6269
6745
|
stmts.setQueueResponseMessageId?.run(responseId, messageId);
|
|
6270
6746
|
stmts.insertMsg.run(
|
|
6271
6747
|
responseId,
|
|
@@ -6280,8 +6756,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6280
6756
|
explicitModel,
|
|
6281
6757
|
modelOrigin,
|
|
6282
6758
|
);
|
|
6759
|
+
stmts.setMessageSourceId?.run(messageId, responseId);
|
|
6283
6760
|
if (String(q?.kind || '') === 'continuation') {
|
|
6284
6761
|
stmts.setMessageKind?.run('continuation', responseId);
|
|
6762
|
+
} else if (settledKind) {
|
|
6763
|
+
stmts.setMessageKind?.run(settledKind, responseId);
|
|
6285
6764
|
}
|
|
6286
6765
|
// The workflow cards persist atomically with the assistant message they
|
|
6287
6766
|
// annotate — keyed directly on the response id (see workflow_runs DDL).
|
|
@@ -6509,6 +6988,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6509
6988
|
messageId: responseId,
|
|
6510
6989
|
message: {
|
|
6511
6990
|
role: 'assistant',
|
|
6991
|
+
// In the message too, as on reload: a stopped marker's Resend finds
|
|
6992
|
+
// its original through it.
|
|
6993
|
+
sourceMessageId: messageId,
|
|
6512
6994
|
text: resolvedText,
|
|
6513
6995
|
attachments: emittedImageAttachments.length ? emittedImageAttachments : undefined,
|
|
6514
6996
|
model: resolvedAssistantModel,
|
|
@@ -6520,18 +7002,27 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6520
7002
|
activities,
|
|
6521
7003
|
thoughts,
|
|
6522
7004
|
executedProvider,
|
|
6523
|
-
kind: String(q?.kind || '') === 'continuation'
|
|
7005
|
+
kind: String(q?.kind || '') === 'continuation'
|
|
7006
|
+
? 'continuation'
|
|
7007
|
+
: (settledKind || undefined),
|
|
6524
7008
|
// Live-appended messages must show their workflow cards without a
|
|
6525
7009
|
// reload; conversation reloads serve the same digests from the DB.
|
|
6526
7010
|
workflowRuns: workflowRuns.length ? workflowRuns : undefined,
|
|
6527
7011
|
},
|
|
6528
7012
|
});
|
|
6529
7013
|
io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
7014
|
+
// An absorbed row is not a finished turn — the reply continues on the
|
|
7015
|
+
// steered message's row, whose own completion sends the one notification.
|
|
7016
|
+
// A stopped steer has no reply at all.
|
|
7017
|
+
if (!settledKind) {
|
|
7018
|
+
void pushDispatchService?.notifyTurnComplete?.({
|
|
7019
|
+
conversationId: targetConversationId,
|
|
7020
|
+
messageId: responseId,
|
|
7021
|
+
// The push service skips turns another relay's agent started.
|
|
7022
|
+
sourceMessageId: messageId,
|
|
7023
|
+
text: resolvedText,
|
|
7024
|
+
});
|
|
7025
|
+
}
|
|
6535
7026
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
6536
7027
|
res.json({ ok: true });
|
|
6537
7028
|
});
|
|
@@ -6639,6 +7130,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6639
7130
|
startedAt: Number(task?.startedAt) || null,
|
|
6640
7131
|
summary: String(task?.summary || '').trim().slice(0, 500) || null,
|
|
6641
7132
|
lastToolName: String(task?.lastToolName || '').trim() || null,
|
|
7133
|
+
lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
|
|
6642
7134
|
totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
|
|
6643
7135
|
subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
|
|
6644
7136
|
model: String(task?.model || '').trim().slice(0, 120) || null,
|
|
@@ -7091,6 +7583,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7091
7583
|
console.log(`[${ts()}] CONTINUATION DROPPED ${messageId?.slice(0,8)} reason=requeue`);
|
|
7092
7584
|
return res.json({ ok: true, dropped: 'continuation' });
|
|
7093
7585
|
}
|
|
7586
|
+
if (q && q.status === 'processing' && q.consumed_at) {
|
|
7587
|
+
// The CLI already took this prompt: any requeue class would run it again.
|
|
7588
|
+
failConsumedQueueRow(q, { reason: 'requeue', attemptId: claimedAttemptId });
|
|
7589
|
+
return res.json({ ok: true, terminal: true, code: STEER_SETTLE_FAILED_STABLE_CODE });
|
|
7590
|
+
}
|
|
7094
7591
|
if (q && q.status === 'processing' && String(req.body?.code || '').trim() === 'relay.provider-mismatch') {
|
|
7095
7592
|
// A routing refusal, not a delivery failure: the legacy relay handed
|
|
7096
7593
|
// back a turn that belongs to a session worker. No retry increment and
|
|
@@ -7105,6 +7602,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7105
7602
|
console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
|
|
7106
7603
|
return res.json({ ok: true, mismatch: true });
|
|
7107
7604
|
}
|
|
7605
|
+
if (q && q.status === 'processing' && String(req.body?.class || '').trim() === 'steering-held') {
|
|
7606
|
+
// The worker refused to push the message because its live turn is
|
|
7607
|
+
// holding steering (an open question card, a compaction, an adoption).
|
|
7608
|
+
// Not a failure: no retry increment, no backoff, and the owner is kept —
|
|
7609
|
+
// the same worker takes it again the moment it signals ready, which is
|
|
7610
|
+
// when the hold ends, so the message steers into the resumed turn.
|
|
7611
|
+
const result = db.prepare(`
|
|
7612
|
+
UPDATE queue
|
|
7613
|
+
SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL
|
|
7614
|
+
WHERE id = ? AND status = 'processing'${claimedAttemptId ? ` AND attempt_id = ?` : ''}
|
|
7615
|
+
`).run(...(claimedAttemptId ? [messageId, claimedAttemptId] : [messageId]));
|
|
7616
|
+
if (result.changes > 0) {
|
|
7617
|
+
settleRelayAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
|
|
7618
|
+
settleRelaySubagentAbortControlsForQueueMessage(messageId, { ok: false, error: 'queue-requeued' });
|
|
7619
|
+
io.emit('message_status', { messageId, conversationId: q.conversation_id, status: 'pending' });
|
|
7620
|
+
console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=steering-held retry=${Number(q.retry_count || 0)}`);
|
|
7621
|
+
}
|
|
7622
|
+
return res.json({ ok: true, held: true });
|
|
7623
|
+
}
|
|
7108
7624
|
if (q && q.status === 'processing') {
|
|
7109
7625
|
const retryCount = Number(q.retry_count || 0) + 1;
|
|
7110
7626
|
if (retryCount >= MAX_REQUEUE_RETRIES) {
|