@oar-sh/oar 0.9.2 → 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/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +25 -10
- package/package.json +7 -4
- package/server/README.md +1 -1
- package/server/claude-worker/claude-session-process.mjs +981 -44
- 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/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-sdk-event-normalizer.mjs +101 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/db-schema.mjs +20 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/api-client.js +22 -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 +34 -8
- package/server/public/app/composer-control-state.mjs +52 -34
- 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 +919 -194
- 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 +145 -5
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
- package/server/public/app/settings-modal.js +8 -6
- package/server/public/app/socket-handlers.js +19 -0
- 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/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +206 -9
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/repositories/message-repository.mjs +61 -18
- package/server/repositories/question-repository.mjs +15 -0
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/messages-routes.mjs +488 -46
- package/server/routes/sessions-routes.mjs +298 -55
- package/server/server-runtime.mjs +65 -25
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- 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/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/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,6 +37,8 @@ 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';
|
|
33
43
|
|
|
34
44
|
export const SESSION_WORKER_OWNER_LEASE_MS = 120_000;
|
|
@@ -177,12 +187,20 @@ function findRecentDuplicateUserMessage({
|
|
|
177
187
|
text = '',
|
|
178
188
|
now = Date.now(),
|
|
179
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 = [],
|
|
180
194
|
} = {}) {
|
|
181
195
|
const fingerprint = normalizeDuplicateMessageText(text);
|
|
182
196
|
if (!fingerprint) return null;
|
|
183
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());
|
|
184
201
|
|
|
185
202
|
for (const row of Array.isArray(recentUserMessages) ? recentUserMessages : []) {
|
|
203
|
+
if (isExempt(row)) continue;
|
|
186
204
|
const rowText = normalizeDuplicateMessageText(row?.text || '');
|
|
187
205
|
if (!rowText || rowText !== fingerprint) continue;
|
|
188
206
|
const rowTime = Date.parse(row?.timestamp || '');
|
|
@@ -193,6 +211,7 @@ function findRecentDuplicateUserMessage({
|
|
|
193
211
|
}
|
|
194
212
|
|
|
195
213
|
for (const row of Array.isArray(recentQueueRows) ? recentQueueRows : []) {
|
|
214
|
+
if (isExempt(row)) continue;
|
|
196
215
|
const rowText = normalizeDuplicateMessageText(row?.text || '');
|
|
197
216
|
if (!rowText || rowText !== fingerprint) continue;
|
|
198
217
|
const rowTime = Date.parse(row?.timestamp || '');
|
|
@@ -1757,6 +1776,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1757
1776
|
sanitizeActivityText,
|
|
1758
1777
|
readSessionTranscriptMessages,
|
|
1759
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,
|
|
1760
1783
|
emitToClientsExceptSessionId,
|
|
1761
1784
|
relayBridgeOwnerService,
|
|
1762
1785
|
relayRestartOrchestrator,
|
|
@@ -1988,6 +2011,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
1988
2011
|
markWorkerError = true,
|
|
1989
2012
|
executedProvider = null,
|
|
1990
2013
|
attemptId = null,
|
|
2014
|
+
// Extra writes that must commit only with the (fenced) failure itself.
|
|
2015
|
+
withinTransaction = null,
|
|
1991
2016
|
}) {
|
|
1992
2017
|
const now = new Date().toISOString();
|
|
1993
2018
|
const responseId = uuidv4();
|
|
@@ -2003,6 +2028,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2003
2028
|
? stmts.setFailedFenced.run(JSON.stringify(failureRecord), messageId, fencedAttemptId)
|
|
2004
2029
|
: stmts.setFailed.run(JSON.stringify(failureRecord), messageId);
|
|
2005
2030
|
if (result.changes === 0) return false;
|
|
2031
|
+
if (typeof withinTransaction === 'function') withinTransaction(now);
|
|
2006
2032
|
stmts.setQueueResponseMessageId?.run(responseId, messageId);
|
|
2007
2033
|
stmts.insertMsg.run(
|
|
2008
2034
|
responseId,
|
|
@@ -2017,6 +2043,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2017
2043
|
modelActual,
|
|
2018
2044
|
modelOrigin,
|
|
2019
2045
|
);
|
|
2046
|
+
stmts.setMessageSourceId?.run(messageId, responseId);
|
|
2020
2047
|
// Provenance travels with terminal failures too — the original hijack
|
|
2021
2048
|
// incident's signature was stolen turns dying on 402, which exited
|
|
2022
2049
|
// exactly here with no executed_provider recorded.
|
|
@@ -2116,6 +2143,66 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2116
2143
|
// pending — an answer may still be on its way in. A row with NO current
|
|
2117
2144
|
// attempt (finalized by a legacy responder after a requeue cleared it) makes
|
|
2118
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
|
+
|
|
2119
2206
|
function cancelPendingRelayQuestionsForMessage(messageId) {
|
|
2120
2207
|
const targetMessageId = String(messageId || '').trim();
|
|
2121
2208
|
if (!targetMessageId || typeof stmts.cancelPendingQuestionsForStaleAttempts?.all !== 'function') return 0;
|
|
@@ -2149,7 +2236,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2149
2236
|
AND rs.sdk_session_id = ?
|
|
2150
2237
|
)
|
|
2151
2238
|
AND (q.next_attempt_at IS NULL OR q.next_attempt_at <= ?)
|
|
2152
|
-
ORDER BY q.
|
|
2239
|
+
ORDER BY q.timestamp ASC, q.rowid ASC
|
|
2153
2240
|
LIMIT 1
|
|
2154
2241
|
`);
|
|
2155
2242
|
const transferPendingOwnerToSession = db.prepare(`
|
|
@@ -2230,6 +2317,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2230
2317
|
WHERE id = ?
|
|
2231
2318
|
AND owner_sdk_session_id = ?
|
|
2232
2319
|
AND status = 'processing'
|
|
2320
|
+
AND consumed_at IS NULL
|
|
2233
2321
|
`);
|
|
2234
2322
|
const findMostRecentConversationForSession = db.prepare(`
|
|
2235
2323
|
SELECT conversation_id
|
|
@@ -2409,15 +2497,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2409
2497
|
}
|
|
2410
2498
|
|
|
2411
2499
|
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
|
-
};
|
|
2500
|
+
return buildRelayStopFailure();
|
|
2421
2501
|
}
|
|
2422
2502
|
|
|
2423
2503
|
function settleRelayAbortControlsForQueueMessage(queueMessageId, { ok = true, note = null, error = null } = {}) {
|
|
@@ -2500,6 +2580,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2500
2580
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
2501
2581
|
continue;
|
|
2502
2582
|
}
|
|
2583
|
+
if (row.consumed_at) {
|
|
2584
|
+
failConsumedQueueRow(row, { reason });
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2503
2587
|
const relayActivityRows = stmts.listActivityByQueueMessage?.all?.(row.id);
|
|
2504
2588
|
const relayStreamRows = stmts.listStreamEventsByQueueMessage?.all?.(row.id);
|
|
2505
2589
|
const fallbackRelayActivities = Array.isArray(relayActivityRows)
|
|
@@ -2747,7 +2831,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2747
2831
|
return rejectSessionBinding(res, sessionState.status, sessionState.error);
|
|
2748
2832
|
}
|
|
2749
2833
|
|
|
2750
|
-
|
|
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;
|
|
2751
2838
|
if (!queueRow) {
|
|
2752
2839
|
return res.json({
|
|
2753
2840
|
ok: true,
|
|
@@ -2946,6 +3033,44 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2946
3033
|
});
|
|
2947
3034
|
});
|
|
2948
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
|
+
|
|
2949
3074
|
app.post('/api/conversation/:conversationId/cancel-queued-turn', auth, (req, res) => {
|
|
2950
3075
|
const conversationId = String(req.params.conversationId || '').trim();
|
|
2951
3076
|
const requestedMessageId = String(req.body?.messageId || '').trim();
|
|
@@ -2979,6 +3104,19 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
2979
3104
|
|
|
2980
3105
|
const currentStatus = String(queueRow.status || '').trim().toLowerCase();
|
|
2981
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
|
+
}
|
|
2982
3120
|
return res.json({
|
|
2983
3121
|
ok: true,
|
|
2984
3122
|
cancelled: false,
|
|
@@ -3851,6 +3989,52 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3851
3989
|
});
|
|
3852
3990
|
});
|
|
3853
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
|
+
|
|
3854
4038
|
// POST /api/message — browser sends a message
|
|
3855
4039
|
app.post('/api/message', auth, async (req, res) => {
|
|
3856
4040
|
const {
|
|
@@ -3877,6 +4061,10 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
3877
4061
|
const requestedRelayMode = normalizeRelayMode(relayMode || mode);
|
|
3878
4062
|
const trimmedText = stripRelayPromptContext(text, requestedRelayMode || relayMode || mode);
|
|
3879
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
|
+
);
|
|
3880
4068
|
const referenceResolution = collectReferenceAttachmentsFromText(trimmedText);
|
|
3881
4069
|
const attachments = mergeMessageAttachments(normalizedAttachments, referenceResolution.attachments);
|
|
3882
4070
|
const imageTarget = rawImageTarget && typeof rawImageTarget === 'object'
|
|
@@ -4346,6 +4534,19 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4346
4534
|
}
|
|
4347
4535
|
}
|
|
4348
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;
|
|
4349
4550
|
if (!shouldCreateConversation) {
|
|
4350
4551
|
const recentUserMessages = stmts.getRecentMessagesDesc.all(conversationId, 20);
|
|
4351
4552
|
const recentQueueRows = db.prepare(`
|
|
@@ -4359,6 +4560,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4359
4560
|
recentUserMessages,
|
|
4360
4561
|
recentQueueRows,
|
|
4361
4562
|
text: trimmedText,
|
|
4563
|
+
exemptMessageIds: resend?.exemptIds || [],
|
|
4362
4564
|
});
|
|
4363
4565
|
if (duplicateMessage) {
|
|
4364
4566
|
return res.json({
|
|
@@ -4464,27 +4666,32 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4464
4666
|
);
|
|
4465
4667
|
linkUploadReferences(convId, msgId, attachments);
|
|
4466
4668
|
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
|
-
|
|
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);
|
|
4486
4694
|
}
|
|
4487
|
-
stmts.deleteDraftUploadRefs?.run?.(convId);
|
|
4488
4695
|
// Cursor allows per-turn model switching, so the pinned provider_model has
|
|
4489
4696
|
// to follow the accepted model; otherwise the conversation list and the
|
|
4490
4697
|
// composer keep restoring the model chosen at bootstrap.
|
|
@@ -4574,14 +4781,16 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4574
4781
|
}
|
|
4575
4782
|
throw error;
|
|
4576
4783
|
}
|
|
4577
|
-
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
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
|
+
}
|
|
4585
4794
|
if (ownerSessionId) {
|
|
4586
4795
|
const existingWorker = sessionWorkerRegistry?.getWorker?.(ownerSessionId) || null;
|
|
4587
4796
|
sessionWorkerRegistry?.upsertWorker?.({
|
|
@@ -4697,6 +4906,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4697
4906
|
...workspaceRootPayload(),
|
|
4698
4907
|
referenceAttachmentCount: referenceResolution.attachments.length,
|
|
4699
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),
|
|
4700
4912
|
imageOperation: imageOperation
|
|
4701
4913
|
? {
|
|
4702
4914
|
id: imageOperation.id,
|
|
@@ -4711,6 +4923,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4711
4923
|
touchCli();
|
|
4712
4924
|
const requester = readBridgeIdentity(req);
|
|
4713
4925
|
const requesterSessionId = normalizeSessionWorkerId(requester?.sessionId);
|
|
4926
|
+
const settleFailedHandled = [];
|
|
4927
|
+
const settleFailedSkipped = [];
|
|
4714
4928
|
const activeQueueMessageId = String(req.body?.activeQueueMessageId || '').trim();
|
|
4715
4929
|
// A persistent-process worker can hold several live rows at once (the
|
|
4716
4930
|
// running turn plus a delivered message queued behind it plus a
|
|
@@ -4746,7 +4960,71 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4746
4960
|
pid: normalizedRequesterPid,
|
|
4747
4961
|
});
|
|
4748
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
|
+
}
|
|
4749
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
|
+
}
|
|
4750
5028
|
if (activeQueueMessageIds.length) {
|
|
4751
5029
|
const now = new Date().toISOString();
|
|
4752
5030
|
const leaseExpiresAt = addMsToIso(now, SESSION_WORKER_OWNER_LEASE_MS);
|
|
@@ -4765,7 +5043,91 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
4765
5043
|
}
|
|
4766
5044
|
relayBridgeOwnerService?.observe?.(requester);
|
|
4767
5045
|
const { pendingCount } = queueCounts();
|
|
4768
|
-
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 });
|
|
4769
5131
|
});
|
|
4770
5132
|
|
|
4771
5133
|
// POST /api/grok-native-session — Grok worker persists the ACP session id so
|
|
@@ -6054,10 +6416,49 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6054
6416
|
});
|
|
6055
6417
|
});
|
|
6056
6418
|
|
|
6419
|
+
const SETTLED_STEER_KINDS = new Set(['folded', 'stopped']);
|
|
6420
|
+
|
|
6057
6421
|
// POST /api/response — CLI submits response
|
|
6058
6422
|
app.post('/api/response', auth, async (req, res) => {
|
|
6059
6423
|
touchCli();
|
|
6060
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
|
+
};
|
|
6061
6462
|
const trimmedText = String(text || '').trim();
|
|
6062
6463
|
const terminalFailure = resolveTerminalFailurePayload(req.body, { fallbackText: trimmedText });
|
|
6063
6464
|
// Final digests of background workflows that settled during this turn —
|
|
@@ -6159,6 +6560,8 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6159
6560
|
failedAt: new Date().toISOString(),
|
|
6160
6561
|
},
|
|
6161
6562
|
attemptId: claimedAttemptId,
|
|
6563
|
+
// An errored turn still took the steers pushed into it.
|
|
6564
|
+
withinTransaction: (atIso) => markSteersConsumed(targetConversationId, atIso),
|
|
6162
6565
|
});
|
|
6163
6566
|
if (!failed) {
|
|
6164
6567
|
const currentRow = stmts.findQById?.get(messageId) || null;
|
|
@@ -6266,6 +6669,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6266
6669
|
? stmts.setDoneFenced.run(resolvedText, messageId, claimedAttemptId)
|
|
6267
6670
|
: stmts.setDone.run(resolvedText, messageId);
|
|
6268
6671
|
if (result.changes === 0) return false;
|
|
6672
|
+
markSteersConsumed(targetConversationId, now);
|
|
6269
6673
|
stmts.setQueueResponseMessageId?.run(responseId, messageId);
|
|
6270
6674
|
stmts.insertMsg.run(
|
|
6271
6675
|
responseId,
|
|
@@ -6280,8 +6684,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6280
6684
|
explicitModel,
|
|
6281
6685
|
modelOrigin,
|
|
6282
6686
|
);
|
|
6687
|
+
stmts.setMessageSourceId?.run(messageId, responseId);
|
|
6283
6688
|
if (String(q?.kind || '') === 'continuation') {
|
|
6284
6689
|
stmts.setMessageKind?.run('continuation', responseId);
|
|
6690
|
+
} else if (settledKind) {
|
|
6691
|
+
stmts.setMessageKind?.run(settledKind, responseId);
|
|
6285
6692
|
}
|
|
6286
6693
|
// The workflow cards persist atomically with the assistant message they
|
|
6287
6694
|
// annotate — keyed directly on the response id (see workflow_runs DDL).
|
|
@@ -6509,6 +6916,9 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6509
6916
|
messageId: responseId,
|
|
6510
6917
|
message: {
|
|
6511
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,
|
|
6512
6922
|
text: resolvedText,
|
|
6513
6923
|
attachments: emittedImageAttachments.length ? emittedImageAttachments : undefined,
|
|
6514
6924
|
model: resolvedAssistantModel,
|
|
@@ -6520,18 +6930,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6520
6930
|
activities,
|
|
6521
6931
|
thoughts,
|
|
6522
6932
|
executedProvider,
|
|
6523
|
-
kind: String(q?.kind || '') === 'continuation'
|
|
6933
|
+
kind: String(q?.kind || '') === 'continuation'
|
|
6934
|
+
? 'continuation'
|
|
6935
|
+
: (settledKind || undefined),
|
|
6524
6936
|
// Live-appended messages must show their workflow cards without a
|
|
6525
6937
|
// reload; conversation reloads serve the same digests from the DB.
|
|
6526
6938
|
workflowRuns: workflowRuns.length ? workflowRuns : undefined,
|
|
6527
6939
|
},
|
|
6528
6940
|
});
|
|
6529
6941
|
io.emit('message_status', { messageId, conversationId: targetConversationId, status: 'done' });
|
|
6530
|
-
|
|
6531
|
-
|
|
6532
|
-
|
|
6533
|
-
|
|
6534
|
-
|
|
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
|
+
}
|
|
6535
6952
|
cancelPendingRelayQuestionsForMessage(messageId);
|
|
6536
6953
|
res.json({ ok: true });
|
|
6537
6954
|
});
|
|
@@ -6639,6 +7056,7 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
6639
7056
|
startedAt: Number(task?.startedAt) || null,
|
|
6640
7057
|
summary: String(task?.summary || '').trim().slice(0, 500) || null,
|
|
6641
7058
|
lastToolName: String(task?.lastToolName || '').trim() || null,
|
|
7059
|
+
lastToolCall: String(task?.lastToolCall || '').replace(/\s+/g, ' ').trim().slice(0, 300) || null,
|
|
6642
7060
|
totalTokens: Number.isFinite(Number(task?.totalTokens)) ? Number(task.totalTokens) : null,
|
|
6643
7061
|
subagentType: String(task?.subagentType || '').trim().slice(0, 120) || null,
|
|
6644
7062
|
model: String(task?.model || '').trim().slice(0, 120) || null,
|
|
@@ -7091,6 +7509,11 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7091
7509
|
console.log(`[${ts()}] CONTINUATION DROPPED ${messageId?.slice(0,8)} reason=requeue`);
|
|
7092
7510
|
return res.json({ ok: true, dropped: 'continuation' });
|
|
7093
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
|
+
}
|
|
7094
7517
|
if (q && q.status === 'processing' && String(req.body?.code || '').trim() === 'relay.provider-mismatch') {
|
|
7095
7518
|
// A routing refusal, not a delivery failure: the legacy relay handed
|
|
7096
7519
|
// back a turn that belongs to a session worker. No retry increment and
|
|
@@ -7105,6 +7528,25 @@ export function registerMessagesRoutes(app, deps) {
|
|
|
7105
7528
|
console.log(`[${ts()}] REQUEUED ${messageId?.slice(0,8)} class=provider-mismatch retry=${Number(q.retry_count || 0)}`);
|
|
7106
7529
|
return res.json({ ok: true, mismatch: true });
|
|
7107
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
|
+
}
|
|
7108
7550
|
if (q && q.status === 'processing') {
|
|
7109
7551
|
const retryCount = Number(q.retry_count || 0) + 1;
|
|
7110
7552
|
if (retryCount >= MAX_REQUEUE_RETRIES) {
|