@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
import { liveResendsByOriginal } from '../services/steer-resend.mjs';
|
|
2
3
|
import { killTmuxSession } from '../services/session-worker-launch-service.mjs';
|
|
3
4
|
|
|
4
5
|
import fs from 'fs';
|
|
@@ -947,13 +948,19 @@ export function normalizePreferredReasoningEffort(value) {
|
|
|
947
948
|
return String(value || '').trim().toLowerCase();
|
|
948
949
|
}
|
|
949
950
|
|
|
950
|
-
|
|
951
|
+
// The web client caps what it saves at the same length (MAX_DRAFT_TEXT_LENGTH
|
|
952
|
+
// in public/app/conversation-draft-sync.mjs); a test pins them together.
|
|
953
|
+
export const MAX_CONVERSATION_DRAFT_LENGTH = 20_000;
|
|
951
954
|
|
|
952
955
|
function normalizeConversationDraftText(value, { maxLength = MAX_CONVERSATION_DRAFT_LENGTH } = {}) {
|
|
953
956
|
const text = String(value ?? '');
|
|
954
957
|
if (!text.trim()) return '';
|
|
955
958
|
if (text.length <= maxLength) return text;
|
|
956
|
-
|
|
959
|
+
// Never split a surrogate pair: SQLite would store the lone half as U+FFFD,
|
|
960
|
+
// and the client (which cuts the same way) would no longer match its draft.
|
|
961
|
+
const cut = text.slice(0, maxLength);
|
|
962
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
963
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
957
964
|
}
|
|
958
965
|
|
|
959
966
|
export function normalizeOptionalIsoTimestamp(value) {
|
|
@@ -1743,11 +1750,16 @@ export function buildConversationMessages({
|
|
|
1743
1750
|
.map((row) => [String(row?.id || '').trim(), row])
|
|
1744
1751
|
.filter(([id]) => !!id),
|
|
1745
1752
|
);
|
|
1753
|
+
// A stopped steer's marker says "Resent" from the data, so it holds across
|
|
1754
|
+
// reloads and devices (the relay refuses a second Resend on the same basis).
|
|
1755
|
+
const liveResends = liveResendsByOriginal(dbMessages, queueRows);
|
|
1746
1756
|
const normalizedDbMessages = Array.isArray(dbMessages)
|
|
1747
1757
|
? dbMessages.map((message) => {
|
|
1748
1758
|
const id = String(message?.id || '').trim();
|
|
1759
|
+
// The queue's link is pruned with old rows; the message's own column
|
|
1760
|
+
// keeps it, so a reload still anchors the reply under its prompt.
|
|
1749
1761
|
const sourceMessageId = message?.role === 'assistant'
|
|
1750
|
-
? (responseMessageToSourceId.get(id) || undefined)
|
|
1762
|
+
? (responseMessageToSourceId.get(id) || String(message?.source_message_id || '').trim() || undefined)
|
|
1751
1763
|
: undefined;
|
|
1752
1764
|
// A user row's queue entry shares its id; assistant rows reach the same
|
|
1753
1765
|
// entry through the response mapping. Both need it for the effort tag.
|
|
@@ -1775,6 +1787,15 @@ export function buildConversationMessages({
|
|
|
1775
1787
|
timestamp: message?.timestamp,
|
|
1776
1788
|
sourceMessageId,
|
|
1777
1789
|
executedProvider: message?.executed_provider || message?.executedProvider || undefined,
|
|
1790
|
+
// 'continuation' badges a self-started turn; 'absorbed' marks a
|
|
1791
|
+
// reply that continues through the next (steered) user message;
|
|
1792
|
+
// 'folded'/'stopped' are a steer's settle markers. Without this the
|
|
1793
|
+
// live-appended badge/merge/marker vanished on reload.
|
|
1794
|
+
kind: message?.kind || undefined,
|
|
1795
|
+
resendOfMessageId: message?.role === 'user' ? (message?.resend_of_message_id || undefined) : undefined,
|
|
1796
|
+
resentAs: (message?.role === 'assistant' && message?.kind === 'stopped' && sourceMessageId)
|
|
1797
|
+
? (liveResends.get(sourceMessageId) || undefined)
|
|
1798
|
+
: undefined,
|
|
1778
1799
|
};
|
|
1779
1800
|
})
|
|
1780
1801
|
: [];
|
|
@@ -2112,9 +2133,11 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2112
2133
|
}
|
|
2113
2134
|
}
|
|
2114
2135
|
|
|
2115
|
-
const SDK_DELETE_WAIT_TIMEOUT_MS = 12_000;
|
|
2116
|
-
const SDK_DELETE_POLL_MS = 200;
|
|
2117
2136
|
const SDK_DELETE_STALE_PROCESSING_MS = 60_000;
|
|
2137
|
+
const CLI_SESSION_DELETE_TIMEOUT_MS = 10_000;
|
|
2138
|
+
const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
|
|
2139
|
+
// A session id that is safe to join into a path under the session-state root.
|
|
2140
|
+
const SESSION_FOLDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
2118
2141
|
const markConversationDeleted = db.prepare(`UPDATE conversations SET status = 'deleted', updated_at = ? WHERE id = ?`);
|
|
2119
2142
|
const getActiveQueueForMessage = db.prepare(`
|
|
2120
2143
|
SELECT id
|
|
@@ -2143,6 +2166,20 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2143
2166
|
WHERE conversation_id = ?
|
|
2144
2167
|
AND status IN ('pending', 'processing', 'parked')
|
|
2145
2168
|
`);
|
|
2169
|
+
const countProcessingConversationQueueRows = db.prepare(`
|
|
2170
|
+
SELECT COUNT(*) AS count
|
|
2171
|
+
FROM queue
|
|
2172
|
+
WHERE conversation_id = ?
|
|
2173
|
+
AND status = 'processing'
|
|
2174
|
+
`);
|
|
2175
|
+
const findOtherLiveConversationForSdkSession = db.prepare(`
|
|
2176
|
+
SELECT id
|
|
2177
|
+
FROM conversations
|
|
2178
|
+
WHERE sdk_session_id = ?
|
|
2179
|
+
AND id != ?
|
|
2180
|
+
AND status != 'deleted'
|
|
2181
|
+
LIMIT 1
|
|
2182
|
+
`);
|
|
2146
2183
|
const getConversationUsageSnapshotAggregate = db.prepare(`
|
|
2147
2184
|
SELECT
|
|
2148
2185
|
COUNT(*) AS snapshot_count,
|
|
@@ -2206,10 +2243,6 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2206
2243
|
const sessionLaunchMutex = createKeyedMutex();
|
|
2207
2244
|
const relaunchCoalescer = createRelaunchCoalescer({ windowMs: RELAUNCH_COALESCE_WINDOW_MS });
|
|
2208
2245
|
|
|
2209
|
-
function sleep(ms) {
|
|
2210
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2211
|
-
}
|
|
2212
|
-
|
|
2213
2246
|
// Layer every enabled provider's models onto the base Copilot catalog.
|
|
2214
2247
|
function buildModelCatalogWithProviders(modelState, openAISettings = getOpenAIProviderSettings()) {
|
|
2215
2248
|
return buildModelCatalogWithGrokProvider(
|
|
@@ -2292,16 +2325,121 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2292
2325
|
return true;
|
|
2293
2326
|
}
|
|
2294
2327
|
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2328
|
+
/**
|
|
2329
|
+
* Whether a conversation is still working, which blocks deleting it: a turn
|
|
2330
|
+
* is running (including one waiting on a question card or an approval) or
|
|
2331
|
+
* background tasks are live. Both only count while the session's worker is
|
|
2332
|
+
* alive: a dead worker runs nothing, and the processing rows or task set it
|
|
2333
|
+
* left behind would otherwise make the conversation undeletable. A session
|
|
2334
|
+
* the registry has never seen may still be running a turn, so its
|
|
2335
|
+
* processing rows count.
|
|
2336
|
+
*
|
|
2337
|
+
* A worker's pid decides whether it is alive; its status only when it has
|
|
2338
|
+
* none. A turn that fails marks a worker that is still running 'error',
|
|
2339
|
+
* and it stays so until its next turn, background tasks and all.
|
|
2340
|
+
*/
|
|
2341
|
+
function describeConversationActivity(conversationId, workerSessionId) {
|
|
2342
|
+
const worker = sessionWorkerRegistry?.getWorker?.(workerSessionId) || null;
|
|
2343
|
+
const workerAlive = !!worker && (worker.pid
|
|
2344
|
+
? isPidAlive(worker.pid)
|
|
2345
|
+
: LIVE_WORKER_STATUSES.has(String(worker.status || '').trim().toLowerCase()));
|
|
2346
|
+
const processingCount = Number(countProcessingConversationQueueRows.get(conversationId)?.count || 0);
|
|
2347
|
+
const taskKeys = [...new Set([conversationId, workerSessionId])];
|
|
2348
|
+
const liveTasks = workerAlive ? taskKeys.flatMap((key) => backgroundTaskStore?.get?.(key) || []) : [];
|
|
2349
|
+
return {
|
|
2350
|
+
turnRunning: processingCount > 0 && (workerAlive || !worker),
|
|
2351
|
+
backgroundTaskCount: liveTasks.length,
|
|
2352
|
+
// Tasks the panel shows no Stop button for (Copilot detached shells).
|
|
2353
|
+
unstoppableTaskCount: liveTasks.filter((task) => task?.stoppable === false).length,
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
function withDeadline(promise, timeoutMs, label) {
|
|
2358
|
+
let timer = null;
|
|
2359
|
+
return Promise.race([
|
|
2360
|
+
promise,
|
|
2361
|
+
new Promise((_, reject) => {
|
|
2362
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs} ms`)), timeoutMs);
|
|
2363
|
+
timer.unref?.();
|
|
2364
|
+
}),
|
|
2365
|
+
]).finally(() => clearTimeout(timer));
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* The Claude CLI's footprint for one session: its transcript
|
|
2370
|
+
* (`<projectDir>/<nativeSessionId>.jsonl`) and the per-session folder beside
|
|
2371
|
+
* it. Nothing else in the project folder is touched — it holds every other
|
|
2372
|
+
* session of that workspace and the project memory.
|
|
2373
|
+
*/
|
|
2374
|
+
function deleteClaudeCliSession({ conversation, runtimeSession }) {
|
|
2375
|
+
const nativeSessionId = String(runtimeSession?.claude_native_session_id || '').trim();
|
|
2376
|
+
if (!nativeSessionId || typeof resolveClaudeSessionRoot !== 'function') return { deleted: null };
|
|
2377
|
+
const anchors = resolveClaudeSessionRoot({
|
|
2378
|
+
claudeNativeSessionId: nativeSessionId,
|
|
2379
|
+
workspaceRootPath: String(
|
|
2380
|
+
conversation?.runtime_workspace_root_path || conversation?.configured_workspace_root_path || '',
|
|
2381
|
+
).trim(),
|
|
2382
|
+
});
|
|
2383
|
+
if (!anchors?.projectDirPath) return { deleted: null };
|
|
2384
|
+
const projectDir = path.resolve(anchors.projectDirPath);
|
|
2385
|
+
const targets = [anchors.transcriptPath, anchors.sessionRootExists ? anchors.sessionRootPath : '']
|
|
2386
|
+
.filter(Boolean)
|
|
2387
|
+
.map((target) => path.resolve(target))
|
|
2388
|
+
.filter((target) => path.dirname(target) === projectDir
|
|
2389
|
+
&& [nativeSessionId, `${nativeSessionId}.jsonl`].includes(path.basename(target)));
|
|
2390
|
+
for (const target of targets) fs.rmSync(target, { recursive: true, force: true });
|
|
2391
|
+
return { deleted: targets.length > 0 };
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* The Copilot session, through the runtime's own deleteSession(). When that
|
|
2396
|
+
* fails the request goes on the SDK delete queue, which a CLI-side consumer
|
|
2397
|
+
* (the extension engine) still works off.
|
|
2398
|
+
*/
|
|
2399
|
+
async function deleteCopilotCliSession({ sdkSessionId, conversationId }) {
|
|
2400
|
+
const stateRoot = String(resolveSessionStateRoot?.() || '').trim();
|
|
2401
|
+
const sessionDir = stateRoot && SESSION_FOLDER_ID_PATTERN.test(sdkSessionId) ? path.join(stateRoot, sdkSessionId) : '';
|
|
2402
|
+
// A conversation deleted before its first turn has no session on disk yet
|
|
2403
|
+
// (the runtime would answer "Session file not found").
|
|
2404
|
+
if (sessionDir && !fs.existsSync(sessionDir)) return { deleted: null };
|
|
2405
|
+
if (typeof sdkSessionImportService?.deleteSession !== 'function') return { deleted: null };
|
|
2406
|
+
try {
|
|
2407
|
+
await withDeadline(
|
|
2408
|
+
sdkSessionImportService.deleteSession(sdkSessionId),
|
|
2409
|
+
CLI_SESSION_DELETE_TIMEOUT_MS,
|
|
2410
|
+
'Copilot deleteSession()',
|
|
2411
|
+
);
|
|
2412
|
+
return { deleted: true };
|
|
2413
|
+
} catch (error) {
|
|
2414
|
+
const reason = String(error?.message || error);
|
|
2415
|
+
if (/Session file not found/i.test(reason)) {
|
|
2416
|
+
// The runtime keeps no session for it, only this session's own
|
|
2417
|
+
// folder (a run that never wrote its session file): nothing to retry.
|
|
2418
|
+
if (sessionDir) fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
2419
|
+
return { deleted: sessionDir ? true : null };
|
|
2420
|
+
}
|
|
2421
|
+
console.warn(`[delete] Copilot session ${shortId(sdkSessionId)} was not deleted: ${reason}`);
|
|
2422
|
+
enqueueSdkDeleteRequest(sdkSessionId, conversationId);
|
|
2423
|
+
return { deleted: false, queued: true };
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/** The conversation's CLI-side session, per provider; null where the relay keeps none. */
|
|
2428
|
+
async function deleteCliSession({ conversation, conversationId, sdkSessionId, runtimeSession }) {
|
|
2429
|
+
const provider = String(runtimeSession?.provider_type || '').trim().toLowerCase();
|
|
2430
|
+
try {
|
|
2431
|
+
if (provider === 'claude') return deleteClaudeCliSession({ conversation, runtimeSession });
|
|
2432
|
+
if (!sdkSessionId) return { deleted: null };
|
|
2433
|
+
if (!provider || provider === 'github' || provider === 'openai') {
|
|
2434
|
+
return await deleteCopilotCliSession({ sdkSessionId, conversationId });
|
|
2435
|
+
}
|
|
2436
|
+
return { deleted: null };
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
// The conversation is already tombstoned: a file the OS will not let go
|
|
2439
|
+
// of must not leave it half deleted.
|
|
2440
|
+
console.warn(`[delete] CLI session of ${shortId(conversationId)} was not deleted: ${error?.message || error}`);
|
|
2441
|
+
return { deleted: false };
|
|
2442
|
+
}
|
|
2305
2443
|
}
|
|
2306
2444
|
|
|
2307
2445
|
function finalizeDeletedConversationsForSdkSession(sdkSessionId) {
|
|
@@ -2436,7 +2574,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
2436
2574
|
(stmts.getSharedMessages || stmts.getMessages).all(resolvedConversationId),
|
|
2437
2575
|
);
|
|
2438
2576
|
const queueRows = db.prepare(`
|
|
2439
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
2577
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
2440
2578
|
FROM queue
|
|
2441
2579
|
WHERE conversation_id = ?
|
|
2442
2580
|
`).all(resolvedConversationId);
|
|
@@ -3181,7 +3319,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3181
3319
|
});
|
|
3182
3320
|
const dbMessages = stmts.getMessages.all(conversationId);
|
|
3183
3321
|
const queueRows = db.prepare(`
|
|
3184
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
3322
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
3185
3323
|
FROM queue
|
|
3186
3324
|
WHERE conversation_id = ?
|
|
3187
3325
|
`).all(conversationId);
|
|
@@ -3583,7 +3721,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3583
3721
|
});
|
|
3584
3722
|
const dbMessages = stmts.getMessages.all(resolvedConversationId);
|
|
3585
3723
|
const queueRows = db.prepare(`
|
|
3586
|
-
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model
|
|
3724
|
+
SELECT id, response_message_id, text, timestamp, retry_count, reasoning_effort, model, status
|
|
3587
3725
|
FROM queue
|
|
3588
3726
|
WHERE conversation_id = ?
|
|
3589
3727
|
`).all(resolvedConversationId);
|
|
@@ -3854,15 +3992,30 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3854
3992
|
if (!existing || String(existing.status || '').trim() === 'deleted') {
|
|
3855
3993
|
return res.status(404).json({ error: 'Conversation not found' });
|
|
3856
3994
|
}
|
|
3857
|
-
|
|
3858
|
-
|
|
3995
|
+
// A null base means "I have seen no draft" and conflicts with any versioned
|
|
3996
|
+
// draft — but only from clients that declare the versioned protocol. Older
|
|
3997
|
+
// tabs also send an explicit null (after every keystroke), and a 409 there
|
|
3998
|
+
// would revert their typing, so for them null stays unconditional.
|
|
3999
|
+
const baseDraftUpdatedAtValue = req.body?.baseDraftUpdatedAt !== undefined
|
|
4000
|
+
? req.body.baseDraftUpdatedAt
|
|
4001
|
+
: req.body?.base_draft_updated_at;
|
|
4002
|
+
const speaksVersionedDraftProtocol = Number(req.body?.draftSyncVersion) >= 2;
|
|
4003
|
+
const comparesDraftVersion = baseDraftUpdatedAtValue !== undefined
|
|
4004
|
+
&& (baseDraftUpdatedAtValue !== null || speaksVersionedDraftProtocol);
|
|
3859
4005
|
const normalizedBaseDraftUpdatedAt = normalizeOptionalIsoTimestamp(baseDraftUpdatedAtValue);
|
|
3860
4006
|
const suppliedBaseTimestamp = String(baseDraftUpdatedAtValue ?? '').trim();
|
|
3861
4007
|
if (comparesDraftVersion && suppliedBaseTimestamp && !normalizedBaseDraftUpdatedAt) {
|
|
3862
4008
|
return res.status(400).json({ error: 'Invalid baseDraftUpdatedAt timestamp' });
|
|
3863
4009
|
}
|
|
3864
4010
|
const existingDraftUpdatedAt = normalizeOptionalIsoTimestamp(existing.draft_updated_at);
|
|
3865
|
-
|
|
4011
|
+
// A pagehide copy taken while this client's own send was in flight carries
|
|
4012
|
+
// the pre-send base; the send transaction has since moved the version. That
|
|
4013
|
+
// move (recorded with the sender's client id) is the only mismatch it may
|
|
4014
|
+
// pass — a newer draft from anyone else still conflicts.
|
|
4015
|
+
const acceptsOwnSendVersion = req.body?.acceptOwnSend === true
|
|
4016
|
+
&& !!senderClientId
|
|
4017
|
+
&& String(existing.draft_updated_by_client_id || '') === senderClientId;
|
|
4018
|
+
if (!acceptsOwnSendVersion && hasConversationDraftVersionConflict({
|
|
3866
4019
|
existingDraftUpdatedAt,
|
|
3867
4020
|
baseDraftUpdatedAt: normalizedBaseDraftUpdatedAt,
|
|
3868
4021
|
compareEnabled: comparesDraftVersion,
|
|
@@ -3874,6 +4027,7 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
3874
4027
|
conflict: true,
|
|
3875
4028
|
conversationId,
|
|
3876
4029
|
draftText: String(existing.draft_text || ''),
|
|
4030
|
+
draftAttachments: parseDraftAttachmentsColumn(existing.draft_attachments),
|
|
3877
4031
|
draftUpdatedAt: existingDraftUpdatedAt,
|
|
3878
4032
|
draftUpdatedByClientId: existing.draft_updated_by_client_id || null,
|
|
3879
4033
|
});
|
|
@@ -4275,42 +4429,131 @@ export function registerSessionsRoutes(app, deps) {
|
|
|
4275
4429
|
}
|
|
4276
4430
|
|
|
4277
4431
|
try {
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
resolveSessionStateRoot,
|
|
4288
|
-
});
|
|
4289
|
-
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
4290
|
-
hardDeleteConversationRows(id);
|
|
4291
|
-
deleteOrphanedUploads(orphanedUploads);
|
|
4292
|
-
io.emit('conversation_deleted', { conversationId: id });
|
|
4293
|
-
return res.json({ ok: true });
|
|
4294
|
-
}
|
|
4295
|
-
|
|
4296
|
-
markConversationDeleted.run(new Date().toISOString(), id);
|
|
4297
|
-
stmts.markDeletedSdkSession.run(sdkSessionId, new Date().toISOString());
|
|
4298
|
-
if (sdkSessionId !== id) {
|
|
4299
|
-
// Also tombstone the conversation id itself for legacy/synthetic sdk id fallback paths.
|
|
4300
|
-
stmts.markDeletedSdkSession.run(id, new Date().toISOString());
|
|
4301
|
-
}
|
|
4302
|
-
enqueueSdkDeleteRequest(sdkSessionId, id);
|
|
4303
|
-
const awaited = await waitForSdkDeleteCompletion(sdkSessionId);
|
|
4304
|
-
if (awaited.completed) return res.json({ ok: true });
|
|
4305
|
-
|
|
4306
|
-
io.emit('conversation_delete_pending', { conversationId: id });
|
|
4307
|
-
return res.json({ ok: true, pending: true });
|
|
4432
|
+
// Session workers are keyed by the SDK session id, which is the
|
|
4433
|
+
// conversation id until a session is bound. Serialized with launches and
|
|
4434
|
+
// relaunches of that session, and with a second delete of it.
|
|
4435
|
+
const workerSessionId = String(existing.sdk_session_id || '').trim() || id;
|
|
4436
|
+
const outcome = await sessionLaunchMutex.runExclusive(
|
|
4437
|
+
workerSessionId,
|
|
4438
|
+
() => deleteConversationNow(id),
|
|
4439
|
+
);
|
|
4440
|
+
return res.status(outcome.statusCode).json(outcome.body);
|
|
4308
4441
|
} catch (error) {
|
|
4309
4442
|
console.warn(`[archive] Delete failed for ${id}: ${error?.message || error}`);
|
|
4310
4443
|
return res.status(500).json({ error: 'Failed to delete conversation' });
|
|
4311
4444
|
}
|
|
4312
4445
|
});
|
|
4313
4446
|
|
|
4447
|
+
/** DELETE /api/conversation/:id under its session's lock; see the route. */
|
|
4448
|
+
async function deleteConversationNow(id) {
|
|
4449
|
+
const existing = stmts.getConvAnyStatus.get(id);
|
|
4450
|
+
if (!existing) return { statusCode: 200, body: { ok: true, alreadyDeleted: true } };
|
|
4451
|
+
const sdkSessionId = String(existing.sdk_session_id || '').trim() || null;
|
|
4452
|
+
const workerSessionId = sdkSessionId || id;
|
|
4453
|
+
|
|
4454
|
+
const activity = describeConversationActivity(id, workerSessionId);
|
|
4455
|
+
if (activity.turnRunning) {
|
|
4456
|
+
return {
|
|
4457
|
+
statusCode: 409,
|
|
4458
|
+
body: {
|
|
4459
|
+
ok: false,
|
|
4460
|
+
error: 'conversation-active',
|
|
4461
|
+
reason: 'turn-running',
|
|
4462
|
+
message: 'This conversation is still working on a reply. Stop it, then delete it.',
|
|
4463
|
+
},
|
|
4464
|
+
};
|
|
4465
|
+
}
|
|
4466
|
+
if (activity.backgroundTaskCount > 0) {
|
|
4467
|
+
return {
|
|
4468
|
+
statusCode: 409,
|
|
4469
|
+
body: {
|
|
4470
|
+
ok: false,
|
|
4471
|
+
error: 'conversation-active',
|
|
4472
|
+
reason: 'background-tasks',
|
|
4473
|
+
// Kill session stops the session's worker, and the delete counts no
|
|
4474
|
+
// tasks for a conversation whose worker is gone.
|
|
4475
|
+
message: activity.unstoppableTaskCount > 0
|
|
4476
|
+
? 'Background tasks are still running in this conversation, and some have no Stop button. Use Kill session in its ⋯ menu, then delete it.'
|
|
4477
|
+
: 'Background tasks are still running in this conversation. Stop them in the task panel, then delete it.',
|
|
4478
|
+
},
|
|
4479
|
+
};
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4482
|
+
// A worker and CLI session that another live conversation still uses
|
|
4483
|
+
// stay; only this conversation's own data goes.
|
|
4484
|
+
const sharedWithConversationId = sdkSessionId
|
|
4485
|
+
? String(findOtherLiveConversationForSdkSession.get(sdkSessionId, id)?.id || '').trim() || null
|
|
4486
|
+
: null;
|
|
4487
|
+
// A Copilot session the relay only imported, and never ran, belongs to
|
|
4488
|
+
// whoever started it in the CLI. Deleting the conversation hides and
|
|
4489
|
+
// tombstones it, and nothing more: the relay started no process for it
|
|
4490
|
+
// (the process scan would find that user's own `copilot --resume`), and
|
|
4491
|
+
// its CLI session stays. Asked before the stop, which removes the registry
|
|
4492
|
+
// entry that shows the relay has run it.
|
|
4493
|
+
const importedOnly = !!sdkSessionId && sdkSessionImportService?.isImportedOnly?.(sdkSessionId) === true;
|
|
4494
|
+
|
|
4495
|
+
// An idle worker is stopped before anything is deleted: the route used to
|
|
4496
|
+
// leave it running (and holding its workspace) until the relay restarted.
|
|
4497
|
+
// Nothing is touched when it will not die.
|
|
4498
|
+
let workerStopped = false;
|
|
4499
|
+
if (!sharedWithConversationId && !importedOnly) {
|
|
4500
|
+
const stopped = await stopIdleWorkspaceRootSession({
|
|
4501
|
+
sdkSessionId: workerSessionId,
|
|
4502
|
+
worker: sessionWorkerRegistry?.getWorker?.(workerSessionId) || null,
|
|
4503
|
+
sessionWorkerSupervisor,
|
|
4504
|
+
sessionWorkerRegistry,
|
|
4505
|
+
sessionWorkerProcessInspector,
|
|
4506
|
+
stopOverrides: sessionWorkerStopOverrides,
|
|
4507
|
+
});
|
|
4508
|
+
if (!stopped.ok) {
|
|
4509
|
+
return {
|
|
4510
|
+
statusCode: 409,
|
|
4511
|
+
body: {
|
|
4512
|
+
ok: false,
|
|
4513
|
+
error: 'worker-stop-failed',
|
|
4514
|
+
message: "Couldn't stop this conversation's session, so nothing was deleted. Try again in a moment.",
|
|
4515
|
+
remainingPids: stopped.remainingPids || [],
|
|
4516
|
+
},
|
|
4517
|
+
};
|
|
4518
|
+
}
|
|
4519
|
+
workerStopped = (stopped.stoppedPids || []).length > 0;
|
|
4520
|
+
// Re-armed after the stop, which can outlast the kill block's grace
|
|
4521
|
+
// window: nothing may respawn a worker for the rows deleted below.
|
|
4522
|
+
sessionWorkerSupervisor?.markKilled?.(workerSessionId);
|
|
4523
|
+
for (const key of new Set([id, workerSessionId])) backgroundTaskStore?.replace?.(key, []);
|
|
4524
|
+
}
|
|
4525
|
+
|
|
4526
|
+
const deletedAt = new Date().toISOString();
|
|
4527
|
+
markConversationDeleted.run(deletedAt, id);
|
|
4528
|
+
// Tombstones keep the SDK session importer from bringing it back.
|
|
4529
|
+
stmts.markDeletedSdkSession.run(sdkSessionId || id, deletedAt);
|
|
4530
|
+
if (sdkSessionId && sdkSessionId !== id) stmts.markDeletedSdkSession.run(id, deletedAt);
|
|
4531
|
+
|
|
4532
|
+
const cliSession = sharedWithConversationId || importedOnly
|
|
4533
|
+
? { deleted: null }
|
|
4534
|
+
: await deleteCliSession({
|
|
4535
|
+
conversation: existing,
|
|
4536
|
+
conversationId: id,
|
|
4537
|
+
sdkSessionId,
|
|
4538
|
+
runtimeSession: stmts.getRuntimeSessionByConversation?.get?.(id) || null,
|
|
4539
|
+
});
|
|
4540
|
+
|
|
4541
|
+
cleanupGeneratedImagesForConversation({
|
|
4542
|
+
conversationId: id,
|
|
4543
|
+
sdkSessionId,
|
|
4544
|
+
messageRows: stmts.getMessages?.all?.(id) || [],
|
|
4545
|
+
parseAttachments,
|
|
4546
|
+
hydrateAttachment,
|
|
4547
|
+
resolveSessionStateRoot,
|
|
4548
|
+
});
|
|
4549
|
+
const orphanedUploads = collectOrphanedUploadsFromConversation(id);
|
|
4550
|
+
hardDeleteConversationRows(id);
|
|
4551
|
+
deleteOrphanedUploads(orphanedUploads);
|
|
4552
|
+
if (sdkSessionId && !cliSession.queued) stmts.deleteSdkDeleteRequest?.run?.(sdkSessionId);
|
|
4553
|
+
io.emit('conversation_deleted', { conversationId: id });
|
|
4554
|
+
return { statusCode: 200, body: { ok: true, workerStopped, cliSessionDeleted: cliSession.deleted } };
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4314
4557
|
// POST /api/conversation/:id/archive — archive conversation
|
|
4315
4558
|
app.post('/api/conversation/:id/archive', auth, async (req, res) => {
|
|
4316
4559
|
const id = String(req.params.id || '').trim();
|
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
normalizeWorkspaceRootKey,
|
|
25
25
|
} from './services/workspace-root-path-policy.mjs';
|
|
26
26
|
import { stopSessionWorkerProcesses } from './services/session-worker-stop-service.mjs';
|
|
27
|
+
import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
|
|
28
|
+
import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
|
|
29
|
+
import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
|
|
27
30
|
import { isRealPathWithinRoot } from './services/workspace-symlink-guard.mjs';
|
|
28
31
|
import { applySchema } from './db-schema.mjs';
|
|
29
32
|
import { createSessionRepository } from './repositories/session-repository.mjs';
|
|
@@ -4880,8 +4883,35 @@ const backgroundTaskStore = {
|
|
|
4880
4883
|
},
|
|
4881
4884
|
};
|
|
4882
4885
|
|
|
4886
|
+
// The live turn among a conversation's processing rows. Mid-turn steering can
|
|
4887
|
+
// leave several processing at once — the running turn, steered messages the
|
|
4888
|
+
// CLI folded into it (no output of their own), a handed-off row still settling
|
|
4889
|
+
// — so the live turn is the row that produced output most recently; with
|
|
4890
|
+
// nothing to separate them yet, the oldest processing row (the turn a steered
|
|
4891
|
+
// message joins) wins. See live-turn-picker.mjs for why recency, not per-row
|
|
4892
|
+
// stream seq. Returns the whole row so callers get relay_mode/timestamps too.
|
|
4893
|
+
function resolveLiveTurnQueueRow(conversationId) {
|
|
4894
|
+
const rows = stmts.listProcessingQueueByConversation?.all(conversationId)
|
|
4895
|
+
|| (stmts.getLatestProcessingQueueByConversation.get(conversationId)
|
|
4896
|
+
? [stmts.getLatestProcessingQueueByConversation.get(conversationId)]
|
|
4897
|
+
: []);
|
|
4898
|
+
if (!rows.length) return null;
|
|
4899
|
+
if (rows.length === 1) return rows[0];
|
|
4900
|
+
const byId = new Map(rows.map((row) => [String(row.id), row]));
|
|
4901
|
+
const liveId = pickLiveTurnRowId(rows.map((row) => {
|
|
4902
|
+
const output = stmts.getLastOutputAtByQueueMessage?.get({ id: row.id }) || null;
|
|
4903
|
+
return {
|
|
4904
|
+
id: String(row.id),
|
|
4905
|
+
lastOutputAtMs: Date.parse(String(output?.last_output_at || '')) || 0,
|
|
4906
|
+
streamDone: Number(output?.stream_done || 0) === 1,
|
|
4907
|
+
processingAtMs: Date.parse(String(row.processing_at || row.timestamp || '')) || 0,
|
|
4908
|
+
};
|
|
4909
|
+
}));
|
|
4910
|
+
return byId.get(liveId) || rows[0];
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4883
4913
|
function inFlightStateForConversation(conversationId) {
|
|
4884
|
-
const row =
|
|
4914
|
+
const row = resolveLiveTurnQueueRow(conversationId);
|
|
4885
4915
|
if (!row) return null;
|
|
4886
4916
|
const streamEvents = relayStreamEventsForQueueMessage(row.id);
|
|
4887
4917
|
const lastStreamEvent = streamEvents.length ? streamEvents[streamEvents.length - 1] : null;
|
|
@@ -4906,17 +4936,17 @@ function inFlightStateForConversation(conversationId) {
|
|
|
4906
4936
|
// retry_count like /api/requeue does, but bypasses that route's budget check,
|
|
4907
4937
|
// so the sweep enforces it here). Minimal mirror of the route-side
|
|
4908
4938
|
// failQueueMessage: mark failed, insert the assistant failure message, emit.
|
|
4909
|
-
function failRecoveredRowTerminally(row, reason) {
|
|
4939
|
+
function failRecoveredRowTerminally(row, reason, { failureRecord = null, failureText: presetText = null } = {}) {
|
|
4910
4940
|
const now = new Date().toISOString();
|
|
4911
4941
|
const responseId = uuidv4();
|
|
4912
4942
|
const retryCount = Number(row?.retry_count || 0) + 1;
|
|
4913
|
-
const failureText = `Relay recovery limit reached after ${retryCount} attempts (${reason}). The worker kept dying before completing this turn, so the message was failed to keep the queue moving. Send it again to retry.`;
|
|
4943
|
+
const failureText = presetText || `Relay recovery limit reached after ${retryCount} attempts (${reason}). The worker kept dying before completing this turn, so the message was failed to keep the queue moving. Send it again to retry.`;
|
|
4914
4944
|
const requestedModel = String(row?.model || '').trim() || null;
|
|
4915
4945
|
const tx = db.transaction(() => {
|
|
4916
4946
|
// setFailed is compare-and-set on status (processing/pending/parked): a
|
|
4917
4947
|
// row that settled between the sweep's listing and this write must not
|
|
4918
4948
|
// grow an orphan failure bubble on top of its real outcome.
|
|
4919
|
-
const result = stmts.setFailed.run(JSON.stringify({
|
|
4949
|
+
const result = stmts.setFailed.run(JSON.stringify(failureRecord || {
|
|
4920
4950
|
kind: 'recovery-limit',
|
|
4921
4951
|
error: 'recovery-limit',
|
|
4922
4952
|
code: 'recovery-limit',
|
|
@@ -4940,6 +4970,7 @@ function failRecoveredRowTerminally(row, reason) {
|
|
|
4940
4970
|
null,
|
|
4941
4971
|
requestedModel ? 'user' : 'auto',
|
|
4942
4972
|
);
|
|
4973
|
+
stmts.setMessageSourceId?.run(row.id, responseId);
|
|
4943
4974
|
stmts.linkActivityToResponse?.run(responseId, row.id);
|
|
4944
4975
|
stmts.linkStreamEventsToResponse?.run(responseId, row.id);
|
|
4945
4976
|
stmts.linkThoughtsToResponse?.run(responseId, row.id);
|
|
@@ -4954,7 +4985,20 @@ function failRecoveredRowTerminally(row, reason) {
|
|
|
4954
4985
|
message: { role: 'assistant', text: failureText, model: requestedModel || 'unknown', mode: String(row?.relay_mode || '').trim() || null, timestamp: now },
|
|
4955
4986
|
});
|
|
4956
4987
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
4957
|
-
console.warn(`${runtimeLogPrefix()}RECOVERY LIMIT ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
|
|
4988
|
+
console.warn(`${runtimeLogPrefix()}${failureRecord ? 'CONSUMED FAILED' : 'RECOVERY LIMIT'} ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} retries=${retryCount} reason=${reason}`);
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4991
|
+
// A row whose prompt the worker's runtime already consumed (queue.consumed_at)
|
|
4992
|
+
// is at-most-once: recovery must never requeue it — a re-delivery would run the
|
|
4993
|
+
// prompt twice — so it fails terminally with the steer-settle wording, naming
|
|
4994
|
+
// the provider's runtime the way the workers' own settle wording does.
|
|
4995
|
+
function failConsumedRowTerminally(row, reason) {
|
|
4996
|
+
const providerType = stmts.getRuntimeSessionByConversation?.get?.(row?.conversation_id)?.provider_type;
|
|
4997
|
+
const failureRecord = buildSteerSettleFailure(row, { agentLabel: steerAgentLabelForProvider(providerType) });
|
|
4998
|
+
failRecoveredRowTerminally(row, reason, {
|
|
4999
|
+
failureRecord: { ...failureRecord, error: 'terminal-error', reason: String(reason || 'recovery') },
|
|
5000
|
+
failureText: `${failureRecord.message} Error code: ${failureRecord.stableCode}. ${failureRecord.guidance}`,
|
|
5001
|
+
});
|
|
4958
5002
|
}
|
|
4959
5003
|
|
|
4960
5004
|
function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso = null } = {}) {
|
|
@@ -4966,7 +5010,9 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
|
|
|
4966
5010
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
4967
5011
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
4968
5012
|
}
|
|
4969
|
-
const
|
|
5013
|
+
const listedRows = stmts.listRecoverableProcessing.all(params);
|
|
5014
|
+
for (const row of listedRows.filter((entry) => entry?.consumed_at)) failConsumedRowTerminally(row, 'stale-recovery');
|
|
5015
|
+
const allRows = listedRows.filter((entry) => !entry?.consumed_at);
|
|
4970
5016
|
if (!allRows.length) return [];
|
|
4971
5017
|
// Enforce the retry budget before requeueing: failing these rows first
|
|
4972
5018
|
// flips them out of 'processing' so the recovery UPDATE below skips them.
|
|
@@ -5772,6 +5818,10 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
|
5772
5818
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
5773
5819
|
continue;
|
|
5774
5820
|
}
|
|
5821
|
+
if (row.consumed_at) {
|
|
5822
|
+
failConsumedRowTerminally(row, `worker-died-${reason}`);
|
|
5823
|
+
continue;
|
|
5824
|
+
}
|
|
5775
5825
|
if (Number(row.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES) {
|
|
5776
5826
|
failRecoveredRowTerminally(row, `worker-died-${reason}`);
|
|
5777
5827
|
continue;
|
|
@@ -5814,11 +5864,15 @@ function handleWorkerSocketClosed({ sessionId, pid = null } = {}) {
|
|
|
5814
5864
|
const sid = String(sessionId || '').trim();
|
|
5815
5865
|
if (!sid) return;
|
|
5816
5866
|
const owned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
5817
|
-
|
|
5867
|
+
// A dead worker's steering snapshot must not keep holding the composer even
|
|
5868
|
+
// when it owned no rows (the hold can outlive them, e.g. a continuation's).
|
|
5869
|
+
const heldSteering = Boolean(sessionWorkerRegistry?.getWorker?.(sid)?.steering);
|
|
5870
|
+
if (!owned.length && !heldSteering) return;
|
|
5818
5871
|
const timer = setTimeout(() => {
|
|
5819
5872
|
try {
|
|
5820
5873
|
if (runtimeShutdownStarted) return;
|
|
5821
5874
|
if (isWorkerSessionProcessAlive(sid, pid)) return; // reconnect or socket flap
|
|
5875
|
+
sessionWorkerRegistry?.clearSteering?.(sid);
|
|
5822
5876
|
const stillOwned = stmts.listProcessingRowsForOwner?.all?.(sid) || [];
|
|
5823
5877
|
if (!stillOwned.length) return;
|
|
5824
5878
|
console.warn(`${runtimeLogPrefix()}WORKER DIED session=${sid.slice(0, 8)} rows=${stillOwned.length} trigger=socket-close — recovering owned turns`);
|
|
@@ -6113,6 +6167,7 @@ const sharedRouteDeps = {
|
|
|
6113
6167
|
previewCardsForResponse,
|
|
6114
6168
|
sanitizeActivityText,
|
|
6115
6169
|
inFlightStateForConversation,
|
|
6170
|
+
resolveLiveTurnQueueRow,
|
|
6116
6171
|
backgroundTaskStore,
|
|
6117
6172
|
// A closure, not the registry itself: this deps object is built before the
|
|
6118
6173
|
// preview lane is constructed further down the file.
|
|
@@ -6523,25 +6578,10 @@ function sanitizeRelayQuestionRequest(requestBody) {
|
|
|
6523
6578
|
}
|
|
6524
6579
|
}
|
|
6525
6580
|
|
|
6581
|
+
// The allow-list lives in services/relay-question-context.mjs (unit-tested);
|
|
6582
|
+
// this binds it to the relay's own mode normalizer.
|
|
6526
6583
|
function sanitizeRelayQuestionContext(rawContext) {
|
|
6527
|
-
|
|
6528
|
-
const context = {};
|
|
6529
|
-
if (typeof rawContext.source === 'string' && rawContext.source.trim()) {
|
|
6530
|
-
context.source = rawContext.source.trim().slice(0, 64);
|
|
6531
|
-
}
|
|
6532
|
-
if (typeof rawContext.rationale === 'string' && rawContext.rationale.trim()) {
|
|
6533
|
-
context.rationale = rawContext.rationale.trim().slice(0, 240);
|
|
6534
|
-
}
|
|
6535
|
-
if (typeof rawContext.queueMessageId === 'string' && rawContext.queueMessageId.trim()) {
|
|
6536
|
-
context.queueMessageId = rawContext.queueMessageId.trim();
|
|
6537
|
-
}
|
|
6538
|
-
if (typeof rawContext.conversationId === 'string' && rawContext.conversationId.trim()) {
|
|
6539
|
-
context.conversationId = rawContext.conversationId.trim();
|
|
6540
|
-
}
|
|
6541
|
-
if (typeof rawContext.relayMode === 'string' && rawContext.relayMode.trim()) {
|
|
6542
|
-
context.relayMode = normalizeRelayMode(rawContext.relayMode) || DEFAULT_RELAY_MODE;
|
|
6543
|
-
}
|
|
6544
|
-
return Object.keys(context).length ? context : null;
|
|
6584
|
+
return sanitizeRelayQuestionContextWith(rawContext, { normalizeRelayMode, defaultRelayMode: DEFAULT_RELAY_MODE });
|
|
6545
6585
|
}
|
|
6546
6586
|
|
|
6547
6587
|
|