@oar-sh/oar 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
package/server/public/sw.js
CHANGED
|
@@ -205,6 +205,9 @@ const SYNC_DB_VERSION = 1;
|
|
|
205
205
|
const SYNC_STORE_NAME = 'outbox';
|
|
206
206
|
const SYNC_TAG = 'copilot-outbox';
|
|
207
207
|
const SYNC_MAX_REPLAY_ATTEMPTS = 10;
|
|
208
|
+
// Mirrors DRAFT_FLUSH_MAX_AGE_MS in app/conversation-draft-sync.mjs: a draft
|
|
209
|
+
// flush replayed later than this would overwrite whatever the draft became.
|
|
210
|
+
const SYNC_DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
|
|
208
211
|
|
|
209
212
|
function openSyncDb() {
|
|
210
213
|
return new Promise((resolve, reject) => {
|
|
@@ -271,6 +274,10 @@ async function replaySyncOutbox() {
|
|
|
271
274
|
try {
|
|
272
275
|
const entries = await readSyncEntries(db);
|
|
273
276
|
for (const { key, value } of entries) {
|
|
277
|
+
if (value?.kind === 'draft-flush' && !(Date.now() - Number(value.createdAt || 0) <= SYNC_DRAFT_FLUSH_MAX_AGE_MS)) {
|
|
278
|
+
await deleteSyncEntry(db, key);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
274
281
|
let response = null;
|
|
275
282
|
try {
|
|
276
283
|
response = await fetch(scopeUrl(String(value.path || '').replace(/^\//, '')), {
|
package/server/relay-tools.md
CHANGED
|
@@ -12,6 +12,8 @@ When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so
|
|
|
12
12
|
At runtime, inject the question and choices from a random item in your quiz pool.
|
|
13
13
|
Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
|
|
14
14
|
|
|
15
|
+
When the user may pick several of the choices (they are not mutually exclusive), set `"multi_select": true` if your ask_user tool offers that field; otherwise begin the question with "Select all that apply:". Either way, expect an answer that lists several choices separated by ", ".
|
|
16
|
+
|
|
15
17
|
In autopilot, still call ask_user when user input is truly blocking, because the relay bridge can surface the question even when the direct SDK question hook is bypassed.
|
|
16
18
|
|
|
17
19
|
For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.
|
package/server/relay.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from './windows-terminal-launcher.mjs';
|
|
29
29
|
import { isValidModelId, normalizeModelIdCandidate } from '../shared/model-id.mjs';
|
|
30
30
|
import {
|
|
31
|
+
buildModelSnapshotFields,
|
|
31
32
|
extractModelDescriptors,
|
|
32
33
|
normalizeContextLimitTokens,
|
|
33
34
|
} from '../shared/model-descriptors.mjs';
|
|
@@ -662,17 +663,28 @@ async function getAvailableModels(session) {
|
|
|
662
663
|
if (!isValidModelId(modelId)) continue;
|
|
663
664
|
const existing = byModelId.get(modelId);
|
|
664
665
|
const cached = cachedModelMetadataById.get(modelId);
|
|
666
|
+
// Highest-priority source wins per field; a lower-priority list only
|
|
667
|
+
// fills what the better one left null. Every field of the shared
|
|
668
|
+
// descriptor is carried, so the snapshot metadata is as rich as the
|
|
669
|
+
// richest list that answered.
|
|
670
|
+
const pick = (field, normalize = (value) => value ?? null) => existing?.[field]
|
|
671
|
+
?? normalize(entry?.[field])
|
|
672
|
+
?? cached?.[field]
|
|
673
|
+
?? null;
|
|
665
674
|
byModelId.set(modelId, {
|
|
666
675
|
modelId,
|
|
667
|
-
contextLimitTokens:
|
|
668
|
-
|
|
669
|
-
?? cached?.contextLimitTokens
|
|
670
|
-
?? null,
|
|
671
|
-
longContextLimitTokens: existing?.longContextLimitTokens
|
|
672
|
-
?? normalizeContextLimitTokens(entry?.longContextLimitTokens)
|
|
673
|
-
?? cached?.longContextLimitTokens
|
|
674
|
-
?? null,
|
|
676
|
+
contextLimitTokens: pick('contextLimitTokens', normalizeContextLimitTokens),
|
|
677
|
+
longContextLimitTokens: pick('longContextLimitTokens', normalizeContextLimitTokens),
|
|
675
678
|
pricing: existing?.pricing || entry?.pricing || cached?.pricing || null,
|
|
679
|
+
displayName: pick('displayName'),
|
|
680
|
+
vendor: pick('vendor'),
|
|
681
|
+
pickerCategory: pick('pickerCategory'),
|
|
682
|
+
preview: existing?.preview === true || entry?.preview === true || cached?.preview === true,
|
|
683
|
+
contextWindowTokens: pick('contextWindowTokens', normalizeContextLimitTokens),
|
|
684
|
+
maxPromptTokens: pick('maxPromptTokens', normalizeContextLimitTokens),
|
|
685
|
+
supportedEfforts: Array.isArray(existing?.supportedEfforts)
|
|
686
|
+
? existing.supportedEfforts
|
|
687
|
+
: (Array.isArray(entry?.supportedEfforts) ? entry.supportedEfforts : (cached?.supportedEfforts ?? null)),
|
|
676
688
|
});
|
|
677
689
|
}
|
|
678
690
|
const models = byModelId.size ? [...byModelId.values()] : [...cachedModelMetadataById.values()];
|
|
@@ -723,19 +735,9 @@ async function publishModelSnapshot(session, reason = 'standalone-relay', force
|
|
|
723
735
|
try {
|
|
724
736
|
const currentModel = await getCurrentModelId(session);
|
|
725
737
|
const availableModels = await getAvailableModels(session);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
.filter((entry) => entry.contextLimitTokens !== null)
|
|
730
|
-
.map((entry) => [entry.modelId, entry.contextLimitTokens]),
|
|
731
|
-
);
|
|
732
|
-
const modelMetadataByModel = Object.fromEntries(
|
|
733
|
-
availableModels.map((entry) => [entry.modelId, {
|
|
734
|
-
defaultContextLimitTokens: entry.contextLimitTokens,
|
|
735
|
-
longContextLimitTokens: entry.longContextLimitTokens,
|
|
736
|
-
pricing: entry.pricing,
|
|
737
|
-
}]),
|
|
738
|
-
);
|
|
738
|
+
// Same shared builder as the SDK worker and the server-side discovery:
|
|
739
|
+
// one definition of the per-model metadata the relay publishes.
|
|
740
|
+
const { models: modelIds, contextLimitsByModel, modelMetadataByModel } = buildModelSnapshotFields(availableModels);
|
|
739
741
|
await apiJson('POST', '/api/models/snapshot', {
|
|
740
742
|
source: `standalone-relay:${reason}`,
|
|
741
743
|
models: modelIds,
|
|
@@ -7,6 +7,9 @@ export function createMessageRepository(db) {
|
|
|
7
7
|
const shareVisibility = createShareVisibilityStatements(db);
|
|
8
8
|
const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
|
|
9
9
|
.some((column) => column.name === 'image_operation_id');
|
|
10
|
+
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
11
|
+
const messagesHaveSourceId = messageColumns.has('source_message_id');
|
|
12
|
+
const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
|
|
10
13
|
const insertQueueSql = queueHasImageOperationId
|
|
11
14
|
? `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at, image_operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?, ?)`
|
|
12
15
|
: `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?)`;
|
|
@@ -17,7 +20,7 @@ export function createMessageRepository(db) {
|
|
|
17
20
|
getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
|
|
18
21
|
setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
|
|
19
22
|
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
20
|
-
getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
23
|
+
getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
21
24
|
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
22
25
|
// Provenance is derived from the authenticated responder identity at
|
|
23
26
|
// /api/response time, never from the response payload.
|
|
@@ -112,12 +115,33 @@ export function createMessageRepository(db) {
|
|
|
112
115
|
// A background-continuation turn: born 'processing' and owned by its
|
|
113
116
|
// session worker — the CLI already started it on its own, so it must
|
|
114
117
|
// never be handed out as deliverable work.
|
|
115
|
-
insertContinuationQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, relay_mode, text, status, kind, timestamp, processing_at, retry_count, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at) VALUES (?, ?, ?, 0, ?, ?, ?, 'processing', 'continuation', ?, ?, 0, ?, ?, ?, ?)`),
|
|
118
|
+
insertContinuationQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, relay_mode, text, status, kind, timestamp, processing_at, retry_count, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at, attempt_id, continuation_op_id) VALUES (?, ?, ?, 0, ?, ?, ?, 'processing', 'continuation', ?, ?, 0, ?, ?, ?, ?, ?, ?)`),
|
|
119
|
+
// Idempotent continuation registration: a worker retry after a lost
|
|
120
|
+
// response resolves to the row its operation id already created.
|
|
121
|
+
findQByContinuationOp: db.prepare(`SELECT * FROM queue WHERE continuation_op_id = ?`),
|
|
116
122
|
setMessageKind: db.prepare(`UPDATE messages SET kind = ? WHERE id = ?`),
|
|
123
|
+
// The durable answer→prompt link (the queue's copy is pruned), so a
|
|
124
|
+
// reload still anchors the reply under the message it answers.
|
|
125
|
+
setMessageSourceId: messagesHaveSourceId
|
|
126
|
+
? db.prepare(`UPDATE messages SET source_message_id = ? WHERE id = ?`)
|
|
127
|
+
: null,
|
|
128
|
+
setMessageResendOf: messagesHaveResendOf
|
|
129
|
+
? db.prepare(`UPDATE messages SET resend_of_message_id = ? WHERE id = ?`)
|
|
130
|
+
: null,
|
|
117
131
|
// Quiet teardown for a continuation whose worker died: there is no
|
|
118
132
|
// user to answer, so it fails without the terminal-failure ceremony.
|
|
119
133
|
dropStaleContinuation: db.prepare(`UPDATE queue SET status = 'failed', processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND kind = 'continuation'`),
|
|
120
|
-
|
|
134
|
+
// Claim ordering (all findPending* variants and the owner-transfer
|
|
135
|
+
// scan): strict send order, rowid breaking same-millisecond ties.
|
|
136
|
+
// Neither retry_count nor next_attempt_at may rank rows: dead-worker
|
|
137
|
+
// recovery and /api/requeue bump retry_count, so ranking by it ran a
|
|
138
|
+
// recovered OLDER message after newer ones in the same conversation,
|
|
139
|
+
// and ranking expired-backoff rows behind fresh arrivals starved them
|
|
140
|
+
// for as long as new messages kept coming (live 2026-09-19: a
|
|
141
|
+
// steer-recovered row waited 5 minutes while newer messages jumped
|
|
142
|
+
// it). Head-of-line protection is the WHERE clause's next_attempt_at
|
|
143
|
+
// backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
|
|
144
|
+
findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY timestamp ASC, rowid ASC LIMIT 1`),
|
|
121
145
|
// Global fallback for requesters without a session identity (the legacy
|
|
122
146
|
// Copilot relay CLI). Conversations bound to a session-worker provider
|
|
123
147
|
// (claude/cursor/grok) are excluded outright: handing one of their turns
|
|
@@ -149,10 +173,8 @@ export function createMessageRepository(db) {
|
|
|
149
173
|
'github'
|
|
150
174
|
)) NOT IN (${sessionWorkerProviderSqlList()})
|
|
151
175
|
ORDER BY
|
|
152
|
-
q.
|
|
153
|
-
|
|
154
|
-
COALESCE(q.next_attempt_at, q.timestamp) ASC,
|
|
155
|
-
q.timestamp ASC
|
|
176
|
+
q.timestamp ASC,
|
|
177
|
+
q.rowid ASC
|
|
156
178
|
LIMIT 1
|
|
157
179
|
`),
|
|
158
180
|
findPendingForWorker: db.prepare(`
|
|
@@ -195,10 +217,8 @@ export function createMessageRepository(db) {
|
|
|
195
217
|
) = ? THEN 0
|
|
196
218
|
ELSE 1
|
|
197
219
|
END ASC,
|
|
198
|
-
q.
|
|
199
|
-
|
|
200
|
-
COALESCE(q.next_attempt_at, q.timestamp) ASC,
|
|
201
|
-
q.timestamp ASC
|
|
220
|
+
q.timestamp ASC,
|
|
221
|
+
q.rowid ASC
|
|
202
222
|
LIMIT 1
|
|
203
223
|
`),
|
|
204
224
|
findPendingForSessionAffinity: db.prepare(`
|
|
@@ -217,10 +237,8 @@ export function createMessageRepository(db) {
|
|
|
217
237
|
NULLIF(c.sdk_session_id, '')
|
|
218
238
|
) = ?
|
|
219
239
|
ORDER BY
|
|
220
|
-
q.
|
|
221
|
-
|
|
222
|
-
COALESCE(q.next_attempt_at, q.timestamp) ASC,
|
|
223
|
-
q.timestamp ASC
|
|
240
|
+
q.timestamp ASC,
|
|
241
|
+
q.rowid ASC
|
|
224
242
|
LIMIT 1
|
|
225
243
|
`),
|
|
226
244
|
countQueueWorkForSessionAffinity: db.prepare(`
|
|
@@ -267,12 +285,13 @@ export function createMessageRepository(db) {
|
|
|
267
285
|
`),
|
|
268
286
|
countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
|
|
269
287
|
countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
|
|
270
|
-
setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
|
|
288
|
+
setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ?, attempt_id = ? WHERE id = ?`),
|
|
271
289
|
setProcessingWithWorkerLease: db.prepare(`
|
|
272
290
|
UPDATE queue
|
|
273
291
|
SET
|
|
274
292
|
status = 'processing',
|
|
275
293
|
processing_at = ?,
|
|
294
|
+
attempt_id = ?,
|
|
276
295
|
owner_sdk_session_id = COALESCE(NULLIF(owner_sdk_session_id, ''), ?),
|
|
277
296
|
owner_assigned_at = COALESCE(owner_assigned_at, ?),
|
|
278
297
|
owner_lease_expires_at = ?,
|
|
@@ -281,15 +300,23 @@ export function createMessageRepository(db) {
|
|
|
281
300
|
`),
|
|
282
301
|
setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
|
|
283
302
|
setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
|
|
303
|
+
// Legacy (unfenced) finalization keeps 'pending' in the set so a
|
|
304
|
+
// response landing after a stale-sweep requeue still finalizes; fenced
|
|
305
|
+
// finalization deliberately does not — a fenced writer whose row went
|
|
306
|
+
// back to pending has by definition been superseded.
|
|
284
307
|
setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
|
|
285
|
-
|
|
308
|
+
setDoneFenced: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND attempt_id = ?`),
|
|
309
|
+
// The status set mirrors canTerminalFail (processing/pending/parked):
|
|
310
|
+
// failure must never overwrite a row another attempt already settled.
|
|
311
|
+
setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status IN ('processing', 'pending', 'parked')`),
|
|
312
|
+
setFailedFenced: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND attempt_id = ?`),
|
|
286
313
|
deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
|
|
287
314
|
findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
|
|
288
315
|
pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
|
|
289
316
|
// Recovery clears only the lease, never the owner: a recovered row must
|
|
290
317
|
// stay routed to its provider worker so the primer respawns that worker
|
|
291
318
|
// instead of the row becoming claimable by the global relay poll.
|
|
292
|
-
recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND processing_at < ?`),
|
|
319
|
+
recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND consumed_at IS NULL AND processing_at < ?`),
|
|
293
320
|
// Staleness is inactivity, not elapsed turn time: owner_last_claimed_at is
|
|
294
321
|
// refreshed by every worker heartbeat for the message it is working on, so a
|
|
295
322
|
// long-but-alive turn keeps moving the cutoff. processing_at is only the
|
|
@@ -299,7 +326,7 @@ export function createMessageRepository(db) {
|
|
|
299
326
|
// A turn blocked on an unanswered AskUserQuestion is never stale — it is
|
|
300
327
|
// waiting on the human, and relay_questions carries its own expiry.
|
|
301
328
|
listRecoverableProcessing: db.prepare(`
|
|
302
|
-
SELECT id, conversation_id, kind, relay_mode, model, retry_count
|
|
329
|
+
SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
|
|
303
330
|
FROM queue
|
|
304
331
|
WHERE status = 'processing'
|
|
305
332
|
AND COALESCE(kind, '') != 'continuation'
|
|
@@ -313,12 +340,14 @@ export function createMessageRepository(db) {
|
|
|
313
340
|
UPDATE queue
|
|
314
341
|
SET status = 'pending',
|
|
315
342
|
processing_at = NULL,
|
|
343
|
+
attempt_id = NULL,
|
|
316
344
|
next_attempt_at = @requeueAt,
|
|
317
345
|
owner_lease_expires_at = NULL,
|
|
318
346
|
owner_last_claimed_at = NULL,
|
|
319
347
|
retry_count = retry_count + 1
|
|
320
348
|
WHERE status = 'processing'
|
|
321
349
|
AND COALESCE(kind, '') != 'continuation'
|
|
350
|
+
AND consumed_at IS NULL
|
|
322
351
|
AND (
|
|
323
352
|
COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
|
|
324
353
|
OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
|
|
@@ -334,7 +363,7 @@ export function createMessageRepository(db) {
|
|
|
334
363
|
// worker can never collect its answer, so holding the row would leak
|
|
335
364
|
// both the row and the question.
|
|
336
365
|
listProcessingRowsForOwner: db.prepare(`
|
|
337
|
-
SELECT id, conversation_id, kind, retry_count
|
|
366
|
+
SELECT id, conversation_id, kind, retry_count, consumed_at, relay_mode, model
|
|
338
367
|
FROM queue
|
|
339
368
|
WHERE status = 'processing'
|
|
340
369
|
AND NULLIF(owner_sdk_session_id, '') = ?
|
|
@@ -343,11 +372,33 @@ export function createMessageRepository(db) {
|
|
|
343
372
|
UPDATE queue
|
|
344
373
|
SET status = 'pending',
|
|
345
374
|
processing_at = NULL,
|
|
375
|
+
attempt_id = NULL,
|
|
346
376
|
next_attempt_at = ?,
|
|
347
377
|
owner_lease_expires_at = NULL,
|
|
348
378
|
owner_last_claimed_at = NULL,
|
|
349
379
|
retry_count = retry_count + 1
|
|
350
|
-
WHERE id = ? AND status = 'processing'
|
|
380
|
+
WHERE id = ? AND status = 'processing' AND consumed_at IS NULL
|
|
381
|
+
`),
|
|
382
|
+
// See the queue.consumed_at migration: at-most-once rows.
|
|
383
|
+
// Fenced like every other worker write: the row must be processing in
|
|
384
|
+
// this conversation, owned by the same worker session (NULL-safe, for
|
|
385
|
+
// routing-disabled installs), and — when the caller names one — still
|
|
386
|
+
// on the attempt it was pushed under.
|
|
387
|
+
markQueueConsumed: db.prepare(`
|
|
388
|
+
UPDATE queue
|
|
389
|
+
SET consumed_at = COALESCE(consumed_at, @at)
|
|
390
|
+
WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
|
|
391
|
+
AND NULLIF(owner_sdk_session_id, '') IS @owner
|
|
392
|
+
AND (@attemptId IS NULL OR attempt_id = @attemptId)
|
|
393
|
+
`),
|
|
394
|
+
// A consumed steer the CLI then replayed as a turn of its own is an
|
|
395
|
+
// ordinary in-flight row again.
|
|
396
|
+
clearQueueConsumed: db.prepare(`
|
|
397
|
+
UPDATE queue
|
|
398
|
+
SET consumed_at = NULL
|
|
399
|
+
WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
|
|
400
|
+
AND NULLIF(owner_sdk_session_id, '') IS @owner
|
|
401
|
+
AND (@attemptId IS NULL OR attempt_id = @attemptId)
|
|
351
402
|
`),
|
|
352
403
|
listProcessingOwnerSessionIds: db.prepare(`
|
|
353
404
|
SELECT DISTINCT NULLIF(owner_sdk_session_id, '') AS sdk_session_id
|
|
@@ -376,6 +427,12 @@ export function createMessageRepository(db) {
|
|
|
376
427
|
listConversationIdsWithActiveQueue: db.prepare(`SELECT DISTINCT conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
|
|
377
428
|
deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
|
|
378
429
|
getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
|
|
430
|
+
// All processing rows, oldest first. Mid-turn steering means a
|
|
431
|
+
// conversation can have more than one processing row at once (the live
|
|
432
|
+
// turn plus a steered message folded into it), so the single-row query
|
|
433
|
+
// above is ambiguous — callers that need "the live turn" resolve it
|
|
434
|
+
// from this list by which row is actually producing output.
|
|
435
|
+
listProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) ASC`),
|
|
379
436
|
parkPendingQueueForRestart: db.prepare(`
|
|
380
437
|
UPDATE queue
|
|
381
438
|
SET
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
export function createQuestionRepository(db) {
|
|
4
4
|
return {
|
|
5
5
|
// relay questions
|
|
6
|
-
insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?)`),
|
|
6
|
+
insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at, attempt_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?, ?)`),
|
|
7
7
|
getQuestion: db.prepare(`SELECT * FROM relay_questions WHERE id = ?`),
|
|
8
8
|
findPendingQuestionByMessage: db.prepare(`SELECT * FROM relay_questions WHERE message_id = ? AND status = 'pending' ORDER BY created_at DESC LIMIT 1`),
|
|
9
9
|
listPendingQuestionsByMessage: db.prepare(`SELECT * FROM relay_questions WHERE message_id = ? AND status = 'pending' ORDER BY created_at ASC`),
|
|
@@ -11,6 +11,9 @@ export function createQuestionRepository(db) {
|
|
|
11
11
|
listQuestions: db.prepare(`SELECT * FROM relay_questions WHERE status = ? AND (? IS NULL OR conversation_id = ?) ORDER BY created_at ASC`),
|
|
12
12
|
timeoutQuestion:db.prepare(`UPDATE relay_questions SET status = 'timed_out' WHERE id = ? AND status = 'pending'`),
|
|
13
13
|
cancelPendingQuestionsByMessage: db.prepare(`UPDATE relay_questions SET status = 'cancelled', answered_at = COALESCE(answered_at, ?) WHERE message_id = ? AND status = 'pending'`),
|
|
14
|
+
// Cards left behind by a superseded processing attempt. NULL-attempt
|
|
15
|
+
// rows (created before the fencing deploy) are deliberately left alone.
|
|
16
|
+
cancelPendingQuestionsForStaleAttempts: db.prepare(`UPDATE relay_questions SET status = 'cancelled', answered_at = COALESCE(answered_at, ?) WHERE message_id = ? AND status = 'pending' AND attempt_id IS NOT NULL AND attempt_id != ? RETURNING id`),
|
|
14
17
|
deleteConvQuestions: db.prepare(`DELETE FROM relay_questions WHERE conversation_id = ?`),
|
|
15
18
|
// RETURNING (run via .all) so the expiry log names exactly the rows
|
|
16
19
|
// that flipped — a separate pre-SELECT could race an answer landing
|
|
@@ -22,6 +25,21 @@ export function createQuestionRepository(db) {
|
|
|
22
25
|
linkActivityToResponse: db.prepare(`UPDATE relay_activity SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
|
|
23
26
|
listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
|
|
24
27
|
listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
|
|
28
|
+
// When a queue row last produced output — its newest stream snapshot
|
|
29
|
+
// (rows are rewritten in place and re-stamped per update) or activity
|
|
30
|
+
// line. The live-turn picker compares rows by this, never by the
|
|
31
|
+
// per-row stream seq. `stream_done` is 1 once the row's main-thread
|
|
32
|
+
// snapshot is final: a row settling after a handoff re-stamps its
|
|
33
|
+
// snapshot with done=1, which must not read as live output.
|
|
34
|
+
getLastOutputAtByQueueMessage: db.prepare(`
|
|
35
|
+
SELECT
|
|
36
|
+
(SELECT MAX(at) FROM (
|
|
37
|
+
SELECT MAX(created_at) AS at FROM relay_stream_events WHERE queue_message_id = @id
|
|
38
|
+
UNION ALL
|
|
39
|
+
SELECT MAX(created_at) AS at FROM relay_activity WHERE queue_message_id = @id
|
|
40
|
+
)) AS last_output_at,
|
|
41
|
+
(SELECT MAX(done) FROM relay_stream_events WHERE queue_message_id = @id AND subagent_run_id IS NULL) AS stream_done
|
|
42
|
+
`),
|
|
25
43
|
deleteConvActivity: db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`),
|
|
26
44
|
|
|
27
45
|
// relay stream events — every update carries the full text-so-far
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
|
|
4
|
-
|
|
5
3
|
export function createSessionRepository(db) {
|
|
6
|
-
const shareVisibility = createShareVisibilityStatements(db);
|
|
7
4
|
const runtimeSessionColumns = new Set(
|
|
8
5
|
db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column?.name || '').trim()),
|
|
9
6
|
);
|
|
@@ -36,36 +33,15 @@ export function createSessionRepository(db) {
|
|
|
36
33
|
archiveConv: db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`),
|
|
37
34
|
deleteConv: db.prepare(`DELETE FROM conversations WHERE id = ?`),
|
|
38
35
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
// Queue and message statements live in message-repository.mjs — the
|
|
37
|
+
// single source of truth. Copies used to live here too; the runtime's
|
|
38
|
+
// {...sessionRepo, ...messageRepo} spread shadowed all of them, and the
|
|
39
|
+
// dead copies silently fossilized (this one's findPending predated the
|
|
40
|
+
// continuation-row exclusion — claiming through it would have replayed
|
|
41
|
+
// a background continuation as a user prompt). The two counts below are
|
|
42
|
+
// the only message/queue statements this repository actually owns.
|
|
44
43
|
getConversationMessageCount: db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE conversation_id = ?`),
|
|
45
44
|
getConversationActiveQueueCount: db.prepare(`SELECT COUNT(*) AS count FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')`),
|
|
46
|
-
getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
|
|
47
|
-
getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
|
|
48
|
-
insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
|
49
|
-
|
|
50
|
-
// queue
|
|
51
|
-
insertQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL)`),
|
|
52
|
-
findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
|
|
53
|
-
countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
|
|
54
|
-
countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
|
|
55
|
-
setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
|
|
56
|
-
setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
|
|
57
|
-
setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
|
|
58
|
-
setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
|
|
59
|
-
setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ?`),
|
|
60
|
-
deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
|
|
61
|
-
findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
|
|
62
|
-
pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
|
|
63
|
-
recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
|
|
64
|
-
listRecoverableProcessing: db.prepare(`SELECT id, conversation_id FROM queue WHERE status = 'processing' AND processing_at < ?`),
|
|
65
|
-
recoverProcessingBefore: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
|
|
66
|
-
listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
|
|
67
|
-
deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
|
|
68
|
-
getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
|
|
69
45
|
|
|
70
46
|
// runtime sessions
|
|
71
47
|
getRuntimeSessionByConversation: db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ?`),
|
|
@@ -128,6 +128,15 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
128
128
|
if (!q || q.status !== 'processing') {
|
|
129
129
|
return res.status(409).json({ error: 'No active relay turn' });
|
|
130
130
|
}
|
|
131
|
+
// Attempt fencing: a caller that names its processing attempt may only
|
|
132
|
+
// open a card while that attempt still owns the row. The stored value is
|
|
133
|
+
// always the row's own attempt_id (server-authoritative), so stale-attempt
|
|
134
|
+
// cleanup covers questions from unfenced callers too.
|
|
135
|
+
const claimedAttemptId = String(req.body.attemptId || '').trim() || null;
|
|
136
|
+
if (claimedAttemptId && String(q.attempt_id || '') !== claimedAttemptId) {
|
|
137
|
+
console.warn(`[${ts()}] STALE ATTEMPT ${String(q.id).slice(0,8)} route=relay-question claimed=${claimedAttemptId.slice(0,8)} current=${String(q.attempt_id || '').slice(0,8) || 'none'}`);
|
|
138
|
+
return res.status(409).json({ error: 'stale_attempt' });
|
|
139
|
+
}
|
|
131
140
|
|
|
132
141
|
const effectiveMessageId = messageId || q.id;
|
|
133
142
|
|
|
@@ -199,6 +208,7 @@ export function registerAskUserRoutes(app, deps) {
|
|
|
199
208
|
continuation.continuationQuestionId,
|
|
200
209
|
now,
|
|
201
210
|
expiresAt,
|
|
211
|
+
String(q.attempt_id || '').trim() || null,
|
|
202
212
|
);
|
|
203
213
|
|
|
204
214
|
const question = formatQuestionRow(stmts.getQuestion.get(questionId));
|