@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
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// control polling, activity channels) but executes turns through the Copilot
|
|
5
5
|
// SDK's headless runtime instead of the `copilot` TUI under a PTY.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
// a live relay with:
|
|
7
|
+
// Run it by hand against a live relay with:
|
|
9
8
|
//
|
|
10
9
|
// COPILOT_WEB_RELAY_CONFIG=server/config.json \
|
|
11
10
|
// COPILOT_WORKSPACE_ROOT=/path/to/workspace \
|
|
@@ -17,10 +16,14 @@ import { fileURLToPath } from 'url';
|
|
|
17
16
|
import {
|
|
18
17
|
loadTokenFromConfig,
|
|
19
18
|
resolveRelayServerUrl,
|
|
20
|
-
} from '
|
|
21
|
-
import { createApiClient } from '
|
|
22
|
-
import { createWorkerWebSocketLink } from '
|
|
23
|
-
import { createHeartbeatController } from '
|
|
19
|
+
} from '../../shared/worker-runtime/config-loader.mjs';
|
|
20
|
+
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
21
|
+
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
22
|
+
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
23
|
+
import {
|
|
24
|
+
createRunnerLinkBridge,
|
|
25
|
+
failSettlingRowsOnShutdown,
|
|
26
|
+
} from '../../shared/worker-runtime/runner-link-wiring.mjs';
|
|
24
27
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
25
28
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
26
29
|
import {
|
|
@@ -69,8 +72,29 @@ async function main() {
|
|
|
69
72
|
api,
|
|
70
73
|
sdkSessionId,
|
|
71
74
|
abortAckNote: 'copilot session aborted',
|
|
75
|
+
// A subagent lane's Stop. Lanes are keyed on the envelope agentId, which
|
|
76
|
+
// is also the runtime's task id, so `rpc.tasks.cancel` is tried; the
|
|
77
|
+
// runtime refuses ids it does not track (then the control answers "not
|
|
78
|
+
// supported" and the whole-turn Stop remains). Background agents have no
|
|
79
|
+
// lane — their Stop is the task panel's. (Late-bound: controls only poll
|
|
80
|
+
// while a turn is live.)
|
|
81
|
+
onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTask(subagentRunId),
|
|
72
82
|
dbg,
|
|
73
83
|
});
|
|
84
|
+
// The relay's *Background task timeout* slider (0 = unlimited) rides every
|
|
85
|
+
// delivery payload, exactly as for the Claude worker: background agents and
|
|
86
|
+
// shells now have cards and a Stop, so the slider governs them too and a cap
|
|
87
|
+
// expiry CANCELS them rather than forgetting them. The env var is an
|
|
88
|
+
// emergency override only.
|
|
89
|
+
const backgroundTaskTimeoutOverrideMs = readOptionalMs('COPILOT_SDK_RELAY_BACKGROUND_TASK_TIMEOUT_MS');
|
|
90
|
+
let backgroundTaskTimeoutMs = backgroundTaskTimeoutOverrideMs ?? 0;
|
|
91
|
+
// The runner reports every flip of its delivery gate; the bridge turns them
|
|
92
|
+
// into the link's worker.unready / worker.ready frames, so a message queued
|
|
93
|
+
// while a question card is open is held in the relay queue and steers into
|
|
94
|
+
// the resumed turn one round trip after the answer.
|
|
95
|
+
const linkBridge = createRunnerLinkBridge();
|
|
96
|
+
// Late-bound: the heartbeat controller is built after the runner.
|
|
97
|
+
let heartbeat = null;
|
|
74
98
|
const turnRunner = createCopilotSdkSessionRunner({
|
|
75
99
|
api,
|
|
76
100
|
sdkSessionId,
|
|
@@ -79,30 +103,42 @@ async function main() {
|
|
|
79
103
|
controlPoller,
|
|
80
104
|
idleShutdownMs: readOptionalMs('COPILOT_SDK_RELAY_IDLE_SHUTDOWN_MS'),
|
|
81
105
|
turnStallTimeoutMs: readOptionalMs('COPILOT_SDK_RELAY_TURN_STALL_TIMEOUT_MS'),
|
|
82
|
-
// How long
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
// How long a deferred model switch may wait for its drain before the
|
|
107
|
+
// explicit selection fails the row (default 10s).
|
|
108
|
+
modelSwitchTimeoutMs: readOptionalMs('COPILOT_SDK_RELAY_MODEL_SWITCH_TIMEOUT_MS'),
|
|
109
|
+
getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
|
|
110
|
+
// A readiness flip is a hold starting or ending (question card, approval,
|
|
111
|
+
// compaction): the composer's steering snapshot changed with it.
|
|
112
|
+
onDeliveryReadinessChange: (ready) => {
|
|
113
|
+
linkBridge.onDeliveryReadinessChange(ready);
|
|
114
|
+
heartbeat?.requestPulse();
|
|
115
|
+
},
|
|
116
|
+
// A message became (or stopped being) cancellable: report it now rather
|
|
117
|
+
// than on the next 10 s heartbeat (coalesced by the controller).
|
|
118
|
+
onCancellableChange: () => heartbeat?.requestPulse(),
|
|
119
|
+
canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
|
|
94
120
|
dbg,
|
|
95
121
|
});
|
|
96
122
|
|
|
97
123
|
let heartbeatTimer = null;
|
|
98
|
-
|
|
124
|
+
heartbeat = createHeartbeatController({
|
|
99
125
|
api,
|
|
100
126
|
pollMs: HEARTBEAT_MS,
|
|
101
127
|
getSessionReady: () => true,
|
|
102
128
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
103
129
|
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
104
130
|
getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
|
|
105
|
-
|
|
131
|
+
// The runner reports { id, attemptId } entries; the heartbeat's claim
|
|
132
|
+
// payload is id-only (its String() coercion would mangle an object), while
|
|
133
|
+
// the crash guard below takes the entries whole so its requeues stay
|
|
134
|
+
// fenced to this attempt.
|
|
135
|
+
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
|
|
136
|
+
// The composer's steering snapshot (Steer / Queue + the hold reason, the
|
|
137
|
+
// un-steerable rows) and the consumed steers whose settle the runner gave
|
|
138
|
+
// up on — the relay fails those itself, never re-running them.
|
|
139
|
+
getSteeringState: () => turnRunner.steeringState(),
|
|
140
|
+
getSettleFailed: () => turnRunner.getSettleFailed(),
|
|
141
|
+
onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
|
|
106
142
|
});
|
|
107
143
|
|
|
108
144
|
const wsLink = createWorkerWebSocketLink({
|
|
@@ -112,8 +148,22 @@ async function main() {
|
|
|
112
148
|
getSessionReady: () => true,
|
|
113
149
|
getSessionId: () => sdkSessionId,
|
|
114
150
|
getPid: () => process.pid,
|
|
151
|
+
// Mid-turn steering opt-in: the link keeps signalling readiness while a
|
|
152
|
+
// delivery is in flight as long as the runner can absorb another message
|
|
153
|
+
// into the live turn, and withdraws it while the runner holds.
|
|
154
|
+
getSteeringReady: () => turnRunner.canAcceptSteering(),
|
|
155
|
+
getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
|
|
156
|
+
// A relay that just (re)started knows nothing of this worker until it
|
|
157
|
+
// heartbeats; the composer's steering snapshot rides that beat.
|
|
158
|
+
onServerHello: () => heartbeat.requestPulse(),
|
|
115
159
|
onDeliver: async (pending, reason) => {
|
|
116
160
|
dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
|
|
161
|
+
// Slider changes reach a running worker on its next delivery; an
|
|
162
|
+
// explicit env override pins the value regardless.
|
|
163
|
+
const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
|
|
164
|
+
if (backgroundTaskTimeoutOverrideMs === undefined && Number.isFinite(deliveredTimeout) && deliveredTimeout >= 0) {
|
|
165
|
+
backgroundTaskTimeoutMs = deliveredTimeout;
|
|
166
|
+
}
|
|
117
167
|
try {
|
|
118
168
|
return await turnRunner.handlePendingPayload(pending);
|
|
119
169
|
} catch (error) {
|
|
@@ -122,19 +172,38 @@ async function main() {
|
|
|
122
172
|
}
|
|
123
173
|
},
|
|
124
174
|
onControl: (control) => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
175
|
+
const type = String(control?.type || '').trim();
|
|
176
|
+
if (type === 'cancel_pushed_message') {
|
|
177
|
+
// Un-steer: pull a pushed-but-unconsumed message back out of the
|
|
178
|
+
// runtime's queue and cancel its row.
|
|
179
|
+
void turnRunner.cancelPushedMessage(control?.messageId).catch((error) => {
|
|
180
|
+
dbg('un-steer failed', error?.message || String(error));
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (type === 'stop_background_task') {
|
|
185
|
+
// The task panel's per-card Stop → rpc.tasks.cancel.
|
|
186
|
+
const taskId = String(control?.taskId || '').trim();
|
|
187
|
+
dbg('stop background task control', taskId);
|
|
188
|
+
void turnRunner.stopBackgroundTask(taskId).catch((error) => {
|
|
189
|
+
dbg('stop background task failed', error?.message || String(error));
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
dbg('worker control ignored (unsupported by the SDK worker)', type || '(none)');
|
|
130
194
|
},
|
|
131
195
|
});
|
|
196
|
+
linkBridge.attach(wsLink);
|
|
132
197
|
|
|
133
198
|
const shutdown = async (signal) => {
|
|
134
199
|
dbg(`shutting down (${signal})`);
|
|
135
200
|
try { wsLink.stop(); } catch {}
|
|
136
201
|
try { heartbeat.stopHeartbeat(); } catch {}
|
|
137
202
|
try { controlPoller.stop(); } catch {}
|
|
203
|
+
// Rows whose prompt the runtime consumed and whose settle marker has not
|
|
204
|
+
// landed are failed terminally, never left for dead-worker recovery to
|
|
205
|
+
// requeue (that would run the prompt twice). Bounded.
|
|
206
|
+
try { await failSettlingRowsOnShutdown({ api, runner: turnRunner }); } catch {}
|
|
138
207
|
// Bounded, like the Cursor worker: stopping the runtime is an RPC, and a
|
|
139
208
|
// hung stop must never make the process ignore the supervisor's SIGTERM.
|
|
140
209
|
try {
|
|
@@ -9,10 +9,10 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
import {
|
|
10
10
|
loadTokenFromConfig,
|
|
11
11
|
resolveRelayServerUrl,
|
|
12
|
-
} from '
|
|
13
|
-
import { createApiClient } from '
|
|
14
|
-
import { createWorkerWebSocketLink } from '
|
|
15
|
-
import { createHeartbeatController } from '
|
|
12
|
+
} from '../../shared/worker-runtime/config-loader.mjs';
|
|
13
|
+
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
14
|
+
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
15
|
+
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
16
16
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
17
17
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
18
18
|
import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
|
|
@@ -131,7 +131,7 @@ async function main() {
|
|
|
131
131
|
installWorkerCrashGuard({
|
|
132
132
|
api,
|
|
133
133
|
workerName: 'cursor-session-worker',
|
|
134
|
-
getActiveQueueMessageIds: () => [turnRunner.
|
|
134
|
+
getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
cursorSubagentRosterFingerprint,
|
|
18
18
|
} from './cursor-subagent-roster.mjs';
|
|
19
19
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
20
|
+
import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
|
|
20
21
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
21
22
|
import { resolveFallbackContextLimitTokens } from '../../shared/context-window-fallbacks.mjs';
|
|
22
23
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
@@ -86,6 +87,17 @@ export function buildCursorPlanReadyBoardPayload({
|
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// Delivered rows carry an attemptId the worker echoes on every write so the
|
|
91
|
+
// server can fence out a superseded attempt (HTTP 409 stale_attempt) instead
|
|
92
|
+
// of letting two attempts interleave writes on the same row. The real api
|
|
93
|
+
// client reports the rejection body as a string; tolerate a parsed object too.
|
|
94
|
+
function isStaleAttemptRejection(error) {
|
|
95
|
+
if (error?.status !== 409) return false;
|
|
96
|
+
const detail = error?.detail;
|
|
97
|
+
const text = typeof detail === 'string' ? detail : String(detail?.error || '');
|
|
98
|
+
return text.includes('stale_attempt');
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
/**
|
|
90
102
|
* Execute one delivered relay turn against the Cursor SDK, streaming
|
|
91
103
|
* normalized events into the relay's activity channels and publishing the
|
|
@@ -131,6 +143,10 @@ export function createCursorTurnRunner({
|
|
|
131
143
|
// Mirrors the Copilot extension's lastPromptedRelayMode: full mode
|
|
132
144
|
// instructions ride on the first message of a mode and on mode changes only.
|
|
133
145
|
let lastNudgedRelayMode = '';
|
|
146
|
+
// Like Grok's once-per-worker capability guidance: the Cursor SDK has no
|
|
147
|
+
// system-prompt seam either, so the media-embed hint rides the first prompt
|
|
148
|
+
// and the session's history keeps it known from then on.
|
|
149
|
+
let mediaGuidanceSent = false;
|
|
134
150
|
|
|
135
151
|
const bridge = createAskUserBridge({
|
|
136
152
|
api,
|
|
@@ -177,6 +193,13 @@ export function createCursorTurnRunner({
|
|
|
177
193
|
return waitingForTurn ? String(activeMessage?.id || '') : '';
|
|
178
194
|
}
|
|
179
195
|
|
|
196
|
+
// Crash-guard shape: the attempt id, when known, fences the dying worker's
|
|
197
|
+
// requeue so it cannot yank back a row a successor attempt now owns.
|
|
198
|
+
function getActiveQueueAttempt() {
|
|
199
|
+
if (!waitingForTurn || !activeMessage?.id) return null;
|
|
200
|
+
return { id: String(activeMessage.id), attemptId: activeMessage.attemptId || null };
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
function isTurnActive() {
|
|
181
204
|
return waitingForTurn;
|
|
182
205
|
}
|
|
@@ -269,6 +292,7 @@ export function createCursorTurnRunner({
|
|
|
269
292
|
mode: message.relayMode || 'agent',
|
|
270
293
|
text,
|
|
271
294
|
...(subagentRunId ? { subagentRunId } : {}),
|
|
295
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
272
296
|
}).catch(() => {});
|
|
273
297
|
}
|
|
274
298
|
|
|
@@ -291,6 +315,7 @@ export function createCursorTurnRunner({
|
|
|
291
315
|
text: payload.text,
|
|
292
316
|
done: payload.done === true,
|
|
293
317
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
318
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
294
319
|
}).catch(() => {});
|
|
295
320
|
return;
|
|
296
321
|
}
|
|
@@ -303,6 +328,7 @@ export function createCursorTurnRunner({
|
|
|
303
328
|
text: payload.text,
|
|
304
329
|
done: payload.done === true,
|
|
305
330
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
331
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
306
332
|
}).catch(() => {});
|
|
307
333
|
return;
|
|
308
334
|
}
|
|
@@ -318,6 +344,7 @@ export function createCursorTurnRunner({
|
|
|
318
344
|
parentSubagentId: payload.parentSubagentId || undefined,
|
|
319
345
|
displayName: payload.displayName || undefined,
|
|
320
346
|
status: payload.status,
|
|
347
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
321
348
|
}).catch(() => {});
|
|
322
349
|
return;
|
|
323
350
|
}
|
|
@@ -396,6 +423,7 @@ export function createCursorTurnRunner({
|
|
|
396
423
|
mode: message.relayMode || 'agent',
|
|
397
424
|
text: String(text || ''),
|
|
398
425
|
done: true,
|
|
426
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
399
427
|
}).catch(() => {});
|
|
400
428
|
}
|
|
401
429
|
|
|
@@ -408,8 +436,18 @@ export function createCursorTurnRunner({
|
|
|
408
436
|
modelOrigin: modelOrigin
|
|
409
437
|
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
410
438
|
...(terminalError ? { terminalError } : {}),
|
|
411
|
-
|
|
412
|
-
|
|
439
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
440
|
+
}).catch(async (error) => {
|
|
441
|
+
// A stale_attempt rejection means the server already handed this row to
|
|
442
|
+
// a newer attempt; requeueing would yank it back, so drop instead.
|
|
443
|
+
if (isStaleAttemptRejection(error)) {
|
|
444
|
+
dbg('response rejected as stale_attempt; dropping superseded turn', message.id);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
await api('POST', '/api/requeue', {
|
|
448
|
+
messageId: message.id,
|
|
449
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
450
|
+
}).catch(() => {});
|
|
413
451
|
});
|
|
414
452
|
}
|
|
415
453
|
|
|
@@ -456,7 +494,10 @@ export function createCursorTurnRunner({
|
|
|
456
494
|
await ensureAgentHandle(message, model);
|
|
457
495
|
const userMessage = buildUserMessageImpl(message);
|
|
458
496
|
const modeNudge = cursorModeNudge(message.relayMode, lastNudgedRelayMode);
|
|
459
|
-
|
|
497
|
+
const mediaGuidance = mediaGuidanceSent ? '' : renderMediaEmbedInstructionBlock();
|
|
498
|
+
if (modeNudge || mediaGuidance) {
|
|
499
|
+
userMessage.text = [mediaGuidance, modeNudge, userMessage.text].filter(Boolean).join('\n\n');
|
|
500
|
+
}
|
|
460
501
|
// Committed only once the send reached the transport (before the loop's
|
|
461
502
|
// break below) — a turn that dies before delivery must re-inject the
|
|
462
503
|
// nudge next time, or a mode change would be silently swallowed.
|
|
@@ -558,6 +599,7 @@ export function createCursorTurnRunner({
|
|
|
558
599
|
// harmless duplicate, so under-committing is the safe direction.
|
|
559
600
|
if (!aborted && !abortController.signal.aborted) {
|
|
560
601
|
lastNudgedRelayMode = pendingNudgedRelayMode;
|
|
602
|
+
if (mediaGuidance) mediaGuidanceSent = true;
|
|
561
603
|
}
|
|
562
604
|
break;
|
|
563
605
|
} catch (error) {
|
|
@@ -622,7 +664,10 @@ export function createCursorTurnRunner({
|
|
|
622
664
|
await publishFinalStream(message, fallbackText);
|
|
623
665
|
await publishResponse(message, { text: fallbackText, model: responseModel });
|
|
624
666
|
} else {
|
|
625
|
-
await api('POST', '/api/requeue', {
|
|
667
|
+
await api('POST', '/api/requeue', {
|
|
668
|
+
messageId: message.id,
|
|
669
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
670
|
+
}).catch(() => {});
|
|
626
671
|
}
|
|
627
672
|
return true;
|
|
628
673
|
}
|
|
@@ -711,6 +756,7 @@ export function createCursorTurnRunner({
|
|
|
711
756
|
return {
|
|
712
757
|
handlePendingPayload,
|
|
713
758
|
getActiveQueueMessageId,
|
|
759
|
+
getActiveQueueAttempt,
|
|
714
760
|
isTurnActive,
|
|
715
761
|
dispose,
|
|
716
762
|
};
|
package/server/db-schema.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// final schema either way.
|
|
10
10
|
import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-workspace-roots-path-key.mjs';
|
|
11
11
|
import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
|
|
12
|
+
import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
|
|
12
13
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
13
14
|
|
|
14
15
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -168,7 +169,8 @@ export const SCHEMA_SQL = `
|
|
|
168
169
|
source_started_at TEXT,
|
|
169
170
|
source_modified_at TEXT,
|
|
170
171
|
updated_at TEXT NOT NULL,
|
|
171
|
-
last_error TEXT
|
|
172
|
+
last_error TEXT,
|
|
173
|
+
origin TEXT
|
|
172
174
|
);
|
|
173
175
|
|
|
174
176
|
CREATE INDEX IF NOT EXISTS idx_sdk_session_imports_status
|
|
@@ -241,6 +243,7 @@ export const SCHEMA_SQL = `
|
|
|
241
243
|
context_limit_tokens INTEGER,
|
|
242
244
|
long_context_limit_tokens INTEGER,
|
|
243
245
|
pricing_json TEXT,
|
|
246
|
+
metadata_json TEXT,
|
|
244
247
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
245
248
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
246
249
|
updated_at TEXT NOT NULL
|
|
@@ -572,6 +575,19 @@ if (!sdkSessionImportColumns.includes('source_started_at')) {
|
|
|
572
575
|
if (!sdkSessionImportColumns.includes('source_modified_at')) {
|
|
573
576
|
db.exec(`ALTER TABLE sdk_session_imports ADD COLUMN source_modified_at TEXT`);
|
|
574
577
|
}
|
|
578
|
+
if (!sdkSessionImportColumns.includes('origin')) {
|
|
579
|
+
// 'imported' = the importer created the conversation's runtime binding, so a
|
|
580
|
+
// later re-import/refresh is safe; 'relay' = the relay queued or executed a
|
|
581
|
+
// turn, making the relay's history authoritative (re-importing would replace
|
|
582
|
+
// it with the raw CLI transcript, instruction preambles included). Completed
|
|
583
|
+
// rows predating the column were written by the importer (relay-born
|
|
584
|
+
// conversations never get a ledger row — the import guards skip them before
|
|
585
|
+
// claiming), so backfilling 'imported' restores refreshability; any that the
|
|
586
|
+
// relay has since continued are re-flagged 'relay' from queue/worker evidence
|
|
587
|
+
// on their next import attempt.
|
|
588
|
+
db.exec(`ALTER TABLE sdk_session_imports ADD COLUMN origin TEXT`);
|
|
589
|
+
db.exec(`UPDATE sdk_session_imports SET origin = 'imported' WHERE status = 'completed'`);
|
|
590
|
+
}
|
|
575
591
|
|
|
576
592
|
const queueColumns = db.prepare(`PRAGMA table_info(queue)`).all().map((c) => c.name);
|
|
577
593
|
if (!queueColumns.includes('model')) {
|
|
@@ -622,12 +638,32 @@ if (!queueColumns.includes('parked_at')) {
|
|
|
622
638
|
if (!queueColumns.includes('parked_target_session_id')) {
|
|
623
639
|
db.exec(`ALTER TABLE queue ADD COLUMN parked_target_session_id TEXT`);
|
|
624
640
|
}
|
|
641
|
+
if (!queueColumns.includes('consumed_at')) {
|
|
642
|
+
// Set when the Claude CLI has provably taken a row's prompt (a steer still
|
|
643
|
+
// pending when the turn it was pushed into finished). A consumed row is
|
|
644
|
+
// at-most-once: every recovery path fails it terminally instead of
|
|
645
|
+
// requeueing it, because a re-delivery would run the prompt a second time.
|
|
646
|
+
db.exec(`ALTER TABLE queue ADD COLUMN consumed_at TEXT`);
|
|
647
|
+
}
|
|
625
648
|
if (!queueColumns.includes('kind')) {
|
|
626
649
|
// 'continuation' rows are synthetic turns for the Claude worker's
|
|
627
650
|
// background-task continuations: born 'processing', never deliverable, and
|
|
628
651
|
// torn down (not requeued) when their worker disappears.
|
|
629
652
|
db.exec(`ALTER TABLE queue ADD COLUMN kind TEXT`);
|
|
630
653
|
}
|
|
654
|
+
if (!queueColumns.includes('attempt_id')) {
|
|
655
|
+
// One processing attempt = one attempt_id: minted on every pending->processing
|
|
656
|
+
// transition (and at continuation birth), cleared by every requeue/recovery,
|
|
657
|
+
// kept on terminal rows so forensics can name the attempt that finalized.
|
|
658
|
+
// Writers that echo it are fenced; writers that omit it ride legacy semantics
|
|
659
|
+
// until the extension engine is retired.
|
|
660
|
+
db.exec(`ALTER TABLE queue ADD COLUMN attempt_id TEXT`);
|
|
661
|
+
}
|
|
662
|
+
if (!queueColumns.includes('continuation_op_id')) {
|
|
663
|
+
// Worker-supplied idempotency key for POST /api/continuation-turn: a retry
|
|
664
|
+
// after a lost response must find the row it already created, not mint a twin.
|
|
665
|
+
db.exec(`ALTER TABLE queue ADD COLUMN continuation_op_id TEXT`);
|
|
666
|
+
}
|
|
631
667
|
|
|
632
668
|
// recent_workspace_roots gained a case-normalized primary key (path_key). The
|
|
633
669
|
// CREATE TABLE IF NOT EXISTS above only covers fresh databases, so upgrade an
|
|
@@ -652,7 +688,20 @@ db.exec(`UPDATE queue SET relay_mode = 'agent' WHERE relay_mode IS NULL OR relay
|
|
|
652
688
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_next_attempt ON queue(status, next_attempt_at, timestamp)`);
|
|
653
689
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_owner_pending ON queue(status, owner_sdk_session_id, next_attempt_at, timestamp)`);
|
|
654
690
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_queue_parked_release ON queue(status, parked_transaction_id, parked_target_session_id, parked_at, timestamp)`);
|
|
691
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_queue_continuation_op ON queue(continuation_op_id) WHERE continuation_op_id IS NOT NULL`);
|
|
655
692
|
migrateImageConversationSchema(db);
|
|
693
|
+
// After the queue's response_message_id exists: the source-link backfill
|
|
694
|
+
// reads it. Not boot-critical — every reader of its columns tolerates their
|
|
695
|
+
// absence — so a failure (e.g. SQLITE_BUSY) is logged and retried next boot
|
|
696
|
+
// instead of keeping the relay down.
|
|
697
|
+
try {
|
|
698
|
+
const steerSettleMarkersMigration = migrateSteerSettleMarkers(db);
|
|
699
|
+
if (steerSettleMarkersMigration.foldStubsRekinded) {
|
|
700
|
+
console.log(`[steering] re-kinded ${steerSettleMarkersMigration.foldStubsRekinded} fold stub(s) as 'folded'`);
|
|
701
|
+
}
|
|
702
|
+
} catch (error) {
|
|
703
|
+
console.warn(`[steering] migration 0004 failed; retrying on next boot: ${error?.message || error}`);
|
|
704
|
+
}
|
|
656
705
|
|
|
657
706
|
const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
|
|
658
707
|
if (runtimeSessionColumns.length) {
|
|
@@ -821,6 +870,11 @@ if (relayQuestionColumns.length && !relayQuestionColumns.includes('structured_an
|
|
|
821
870
|
if (relayQuestionColumns.length && !relayQuestionColumns.includes('request_schema')) {
|
|
822
871
|
db.exec(`ALTER TABLE relay_questions ADD COLUMN request_schema TEXT`);
|
|
823
872
|
}
|
|
873
|
+
if (relayQuestionColumns.length && !relayQuestionColumns.includes('attempt_id')) {
|
|
874
|
+
// Stamped from the queue row at creation so stale-question cleanup can cancel
|
|
875
|
+
// cards left behind by a superseded processing attempt.
|
|
876
|
+
db.exec(`ALTER TABLE relay_questions ADD COLUMN attempt_id TEXT`);
|
|
877
|
+
}
|
|
824
878
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_relay_questions_continuation ON relay_questions(continuation_id, continuation_question_id, status, created_at)`);
|
|
825
879
|
|
|
826
880
|
db.exec(`
|
|
@@ -929,4 +983,10 @@ if (modelVariantColumns.length && !modelVariantColumns.includes('long_context_li
|
|
|
929
983
|
if (modelVariantColumns.length && !modelVariantColumns.includes('pricing_json')) {
|
|
930
984
|
db.exec(`ALTER TABLE model_variants ADD COLUMN pricing_json TEXT`);
|
|
931
985
|
}
|
|
986
|
+
// Per-model catalog metadata (vendor, picker category, real context window,
|
|
987
|
+
// runtime-reported reasoning efforts, ...) persisted with the variant rows so a
|
|
988
|
+
// restart serves it before the next worker/discovery snapshot arrives.
|
|
989
|
+
if (modelVariantColumns.length && !modelVariantColumns.includes('metadata_json')) {
|
|
990
|
+
db.exec(`ALTER TABLE model_variants ADD COLUMN metadata_json TEXT`);
|
|
991
|
+
}
|
|
932
992
|
}
|