@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
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
// its usage is captured and posted, and a user message delivered while it
|
|
45
45
|
// runs is steered into it (answered from its own prompt segment) rather
|
|
46
46
|
// than cross-published into the continuation's row.
|
|
47
|
+
import { randomUUID } from 'crypto';
|
|
48
|
+
|
|
47
49
|
import {
|
|
48
50
|
USER_INPUT_UNSUPPORTED_ANSWER,
|
|
49
51
|
classifyCopilotSessionError,
|
|
@@ -61,11 +63,27 @@ import { resolveCopilotProviderConfig } from './copilot-byok-provider.mjs';
|
|
|
61
63
|
import {
|
|
62
64
|
createBackgroundShellTracker,
|
|
63
65
|
createReplayGate,
|
|
66
|
+
describeSettledAgent,
|
|
64
67
|
describeSettledShell,
|
|
65
68
|
isContinuationOpeningEvent,
|
|
69
|
+
settledAgentFromNotification,
|
|
66
70
|
} from './copilot-continuation-signals.mjs';
|
|
67
|
-
import { createCopilotEventNormalizer } from './copilot-sdk-event-normalizer.mjs';
|
|
71
|
+
import { createCopilotEventNormalizer, formatToolActivityText } from './copilot-sdk-event-normalizer.mjs';
|
|
72
|
+
import {
|
|
73
|
+
DEFAULT_MODEL_SWITCH_TIMEOUT_MS,
|
|
74
|
+
createCopilotModelSwitcher,
|
|
75
|
+
isModelSwitchUnconfirmedError,
|
|
76
|
+
normalizeRelayEffort,
|
|
77
|
+
} from './copilot-model-switch.mjs';
|
|
68
78
|
import { createCopilotQuestionBridge } from './copilot-question-bridge.mjs';
|
|
79
|
+
import {
|
|
80
|
+
RELAY_ASK_USER_TOOL_DESCRIPTION,
|
|
81
|
+
RELAY_ASK_USER_TOOL_NAME,
|
|
82
|
+
RELAY_ASK_USER_TOOL_PARAMETERS,
|
|
83
|
+
formatAskUserToolResult,
|
|
84
|
+
isToolOverrideRejection,
|
|
85
|
+
parseAskUserToolArguments,
|
|
86
|
+
} from './copilot-ask-user-tool.mjs';
|
|
69
87
|
import {
|
|
70
88
|
EXIT_PLAN_BOARD_POSTED_FEEDBACK,
|
|
71
89
|
EXIT_PLAN_NO_BOARD_FEEDBACK,
|
|
@@ -80,6 +98,12 @@ import {
|
|
|
80
98
|
withRelayContext,
|
|
81
99
|
} from './copilot-prompt-context.mjs';
|
|
82
100
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
101
|
+
import { buildModelSnapshotFields, extractModelDescriptors } from '../../shared/model-descriptors.mjs';
|
|
102
|
+
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
103
|
+
import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
104
|
+
import { buildSteerSettleFailure } from '../../shared/steer-settle-failure.mjs';
|
|
105
|
+
import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
|
|
106
|
+
import { withSteerNote } from '../../shared/steer-note.mjs';
|
|
83
107
|
|
|
84
108
|
// How long the runtime may sit with no session activity before the worker
|
|
85
109
|
// closes it. The worker process itself stays up and reconnects lazily on the
|
|
@@ -123,6 +147,45 @@ const DEFAULT_CONTINUATION_RETRY_DELAY_MS = 500;
|
|
|
123
147
|
const CONTINUATION_REGISTRATION_TIMEOUT_MS = 10_000;
|
|
124
148
|
/** Cap on activity lines carried between turns, so a chatty runtime cannot grow them. */
|
|
125
149
|
const MAX_PENDING_ACTIVITIES = 20;
|
|
150
|
+
/**
|
|
151
|
+
* Backstop for the compaction hold: `session.compaction_start` without a
|
|
152
|
+
* matching `_complete` (a runtime that died mid-compaction and was resumed)
|
|
153
|
+
* must not hold steering forever. Sized far above any plausible compaction,
|
|
154
|
+
* like the Claude worker's `compactionStaleMs`.
|
|
155
|
+
*/
|
|
156
|
+
const DEFAULT_COMPACTION_STALE_MS = 10 * 60_000;
|
|
157
|
+
/**
|
|
158
|
+
* Backoff between attempts to save a consumed steer's settle marker (~31 s in
|
|
159
|
+
* all); after the last one the row fails terminally instead. Same ladder as
|
|
160
|
+
* the Claude worker, because it protects the same invariant: a prompt the
|
|
161
|
+
* runtime already consumed is never re-run.
|
|
162
|
+
*/
|
|
163
|
+
const DEFAULT_SETTLE_RETRY_DELAYS_MS = Object.freeze([1_000, 2_000, 4_000, 8_000, 8_000, 8_000]);
|
|
164
|
+
const DEFAULT_MAX_TERMINAL_SETTLE_ATTEMPTS = 5;
|
|
165
|
+
/** Debounce for re-reading the runtime's queued lane after `pending_messages.modified`. */
|
|
166
|
+
const DEFAULT_QUEUE_LANE_REFRESH_MS = 150;
|
|
167
|
+
/**
|
|
168
|
+
* How long a Stop's acknowledgement is held back for the reply of the row it
|
|
169
|
+
* named when that row's early settle is still publishing (see interruptTurn).
|
|
170
|
+
* Bounded: an unresponsive relay must not hold the Stop itself.
|
|
171
|
+
*/
|
|
172
|
+
const STOPPED_ROW_PUBLISH_WAIT_MS = 5_000;
|
|
173
|
+
/**
|
|
174
|
+
* Debounce for re-reading `rpc.tasks.list()` after `session.background_tasks_changed`
|
|
175
|
+
* (which carries no payload and fires ~20 times per shell call).
|
|
176
|
+
*/
|
|
177
|
+
const DEFAULT_TASK_REFRESH_MS = 250;
|
|
178
|
+
/** Trailing throttle for enrichment-only card updates (a live tool call, tokens). */
|
|
179
|
+
const DEFAULT_TASK_PUBLISH_THROTTLE_MS = 2_000;
|
|
180
|
+
/**
|
|
181
|
+
* How long a pushed prompt the runtime had not started when the turn's idle
|
|
182
|
+
* arrived is kept unsettled, waiting for the runtime to open its run. The
|
|
183
|
+
* drain's idle can race a send that resolved a moment earlier; the runtime
|
|
184
|
+
* then starts the prompt right away (`immediate` when idle) and its
|
|
185
|
+
* `user.message` adopts the row as a turn of its own. Same value as the
|
|
186
|
+
* Claude worker's fold grace, for the same reason.
|
|
187
|
+
*/
|
|
188
|
+
const DEFAULT_ORPHAN_GRACE_MS = 2_000;
|
|
126
189
|
|
|
127
190
|
/**
|
|
128
191
|
* Appended to the partial answer when the RUNTIME interrupted the turn on its
|
|
@@ -136,15 +199,27 @@ export const RUNTIME_INTERRUPTED_NOTE =
|
|
|
136
199
|
|
|
137
200
|
/**
|
|
138
201
|
* Published to a steered queue row whose prompt the runtime accepted but never
|
|
139
|
-
* opened work on before the interaction ended. The prompt is still
|
|
140
|
-
* INSIDE the runtime, so it will be answered at the start of the next
|
|
141
|
-
* requeuing the row would run it twice.
|
|
142
|
-
*
|
|
202
|
+
* opened work on before the interaction ended normally. The prompt is still
|
|
203
|
+
* queued INSIDE the runtime, so it will be answered at the start of the next
|
|
204
|
+
* turn — requeuing the row would run it twice. With `mode: "immediate"` the
|
|
205
|
+
* runtime drains every pushed prompt before it idles, so this is a defensive
|
|
206
|
+
* fallback rather than a routine outcome.
|
|
143
207
|
*/
|
|
144
208
|
export const STEERED_ROW_MERGED_NOTE =
|
|
145
209
|
'_(This message was delivered while the previous turn was still running; the reply continues in '
|
|
146
210
|
+ 'the next turn.)_';
|
|
147
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The reply of a continuation row opened only so a background task's question
|
|
214
|
+
* had a row to hang its card off (see `ensureInteractiveTurn`). The runtime
|
|
215
|
+
* produced no root work for it, so nothing else would ever answer the row.
|
|
216
|
+
*/
|
|
217
|
+
export const BACKGROUND_QUESTION_NOTE =
|
|
218
|
+
'System note: a background task asked for input here; it carries on in the background.';
|
|
219
|
+
|
|
220
|
+
/** The provider name in the at-most-once settle failure wording. */
|
|
221
|
+
const SETTLE_AGENT_LABEL = 'Copilot';
|
|
222
|
+
|
|
148
223
|
/**
|
|
149
224
|
* The `trigger` reported to `POST /api/continuation-turn`, matching the value
|
|
150
225
|
* the Claude worker sends so the relay's `CONTINUATION … trigger=` log line and
|
|
@@ -173,6 +248,39 @@ export const DEFAULT_INFINITE_SESSION_CONFIG = Object.freeze({
|
|
|
173
248
|
/** `steerIntoActiveTurn` could not adopt the row; run it as a normal turn. */
|
|
174
249
|
const NOT_STEERED = Symbol('not-steered');
|
|
175
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The attempt-fencing echo for a row's write bodies. Every publish that names
|
|
253
|
+
* a `messageId` also names the attempt it belongs to, so the relay can refuse
|
|
254
|
+
* writes from a superseded attempt (a requeued row re-delivered elsewhere).
|
|
255
|
+
* Omitted entirely when the row carries no attempt id (a pre-fencing relay).
|
|
256
|
+
*/
|
|
257
|
+
function attemptFields(message) {
|
|
258
|
+
return message?.attemptId ? { attemptId: message.attemptId } : {};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A 409 whose detail names `stale_attempt`: this attempt was superseded. */
|
|
262
|
+
function isStaleAttemptError(error) {
|
|
263
|
+
if (Number(error?.status) !== 409) return false;
|
|
264
|
+
const detail = typeof error?.detail === 'string' ? error.detail : JSON.stringify(error?.detail || '');
|
|
265
|
+
return detail.includes('stale_attempt');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether publishing `action` makes its row worth keeping (`entry.showedWork`).
|
|
270
|
+
* A bookkeeping line only tracks background work the task card already shows,
|
|
271
|
+
* and so does a line carried in from between turns (a shell or agent the card
|
|
272
|
+
* showed finishing) — unless it reports a failure. The card lists running
|
|
273
|
+
* tasks only, so a failed agent's card vanishes exactly like a finished one's,
|
|
274
|
+
* and a `subagent.failed` between turns reaches no row: that line is the
|
|
275
|
+
* failure's only trace. It renders only through its row's response, so
|
|
276
|
+
* dropping the row would drop the failure with it.
|
|
277
|
+
*/
|
|
278
|
+
function showsWork(action) {
|
|
279
|
+
if (action.channel === 'init') return false;
|
|
280
|
+
if (action.carried) return action.reportsFailure === true;
|
|
281
|
+
return !action.bookkeeping;
|
|
282
|
+
}
|
|
283
|
+
|
|
176
284
|
export function createCopilotSdkSessionRunner({
|
|
177
285
|
api,
|
|
178
286
|
sdkSessionId,
|
|
@@ -194,15 +302,16 @@ export function createCopilotSdkSessionRunner({
|
|
|
194
302
|
resolveProviderConfigImpl = resolveCopilotProviderConfig,
|
|
195
303
|
// Threading seam for `MessageOptions.mode` ("enqueue" | "immediate").
|
|
196
304
|
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
|
|
305
|
+
// "immediate", always (fake-provider probe against runtime 1.0.88,
|
|
306
|
+
// 2026-09-25). Mid-turn it is a real steer: injected at the next tool
|
|
307
|
+
// boundary as `user.message{delivery:"steering"}` (or, when the model is
|
|
308
|
+
// mid-stream, moved to the FRONT of the queue and run right after as
|
|
309
|
+
// `delivery:"queued"`). When the main loop is idle both modes start a run at
|
|
310
|
+
// once — except that an "enqueue" message sent while a background agent or
|
|
311
|
+
// an attached shell keeps `session.idle` deferred sits FROZEN until that
|
|
312
|
+
// work ends, while an "immediate" one runs immediately. So immediate is the
|
|
313
|
+
// only mode that never strands a message behind background work.
|
|
314
|
+
resolveSendModeImpl = () => 'immediate',
|
|
206
315
|
// Interactive surfaces. Tests inject a fake bridge; nothing here reaches the
|
|
207
316
|
// relay without one.
|
|
208
317
|
createQuestionBridgeImpl = createCopilotQuestionBridge,
|
|
@@ -219,6 +328,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
219
328
|
// fails terminally instead of holding the queue row until the relay's own
|
|
220
329
|
// delivery watchdog gives up.
|
|
221
330
|
turnStallTimeoutMs = DEFAULT_TURN_STALL_TIMEOUT_MS,
|
|
331
|
+
// How long a `deferred: true` model switch may wait for its
|
|
332
|
+
// `session.model_change` drain before the explicit selection counts as
|
|
333
|
+
// unconfirmed and fails the row (`COPILOT_SDK_RELAY_MODEL_SWITCH_TIMEOUT_MS`).
|
|
334
|
+
modelSwitchTimeoutMs = DEFAULT_MODEL_SWITCH_TIMEOUT_MS,
|
|
222
335
|
// How long live detached shells alone may keep the runtime up (0 = no
|
|
223
336
|
// limit). Read through a getter, like the Claude worker's
|
|
224
337
|
// `getBackgroundTaskTimeoutMs`, so a future settings push can move it without
|
|
@@ -229,13 +342,55 @@ export function createCopilotSdkSessionRunner({
|
|
|
229
342
|
// How long a settled continuation waits for its row before giving up on it.
|
|
230
343
|
// Must outlast the registration's own retries; a test shortens it.
|
|
231
344
|
continuationRegistrationTimeoutMs = CONTINUATION_REGISTRATION_TIMEOUT_MS,
|
|
345
|
+
compactionStaleMs = DEFAULT_COMPACTION_STALE_MS,
|
|
346
|
+
settleRetryDelaysMs = DEFAULT_SETTLE_RETRY_DELAYS_MS,
|
|
347
|
+
maxTerminalSettleAttempts = DEFAULT_MAX_TERMINAL_SETTLE_ATTEMPTS,
|
|
348
|
+
queueLaneRefreshMs = DEFAULT_QUEUE_LANE_REFRESH_MS,
|
|
349
|
+
orphanGraceMs = DEFAULT_ORPHAN_GRACE_MS,
|
|
350
|
+
// Register the relay's own `ask_user` (with `multi_select`) over the
|
|
351
|
+
// runtime's built-in one. A runtime that refuses the override falls back
|
|
352
|
+
// to the built-in for the rest of this worker's life.
|
|
353
|
+
relayAskUserTool = true,
|
|
354
|
+
taskRefreshMs = DEFAULT_TASK_REFRESH_MS,
|
|
355
|
+
taskPublishThrottleMs = DEFAULT_TASK_PUBLISH_THROTTLE_MS,
|
|
356
|
+
// Called with `true`/`false` whenever the runner flips between accepting
|
|
357
|
+
// deliveries and holding them (see isDeliveryHeld). The worker wires it to
|
|
358
|
+
// the socket link: a hold withdraws the relay's readiness, and its end
|
|
359
|
+
// re-arms it at once so a held message steers into the resumed turn.
|
|
360
|
+
onDeliveryReadinessChange = () => {},
|
|
361
|
+
// False while the connected relay does not understand the `steering-held`
|
|
362
|
+
// hand-back (a worker updated on disk before the relay restarted): a held
|
|
363
|
+
// delivery is then pushed the legacy way instead of being handed back into
|
|
364
|
+
// a retry-and-backoff requeue.
|
|
365
|
+
canHandBackHeldDelivery = () => true,
|
|
366
|
+
// Called when the set of recallable (un-steerable) rows changes. The worker
|
|
367
|
+
// wires it to an immediate heartbeat: the client's Cancel appears from the
|
|
368
|
+
// heartbeat snapshot, and a 10 s wait would often outlast the window in
|
|
369
|
+
// which the runtime still holds the message (live check 2026-09-25).
|
|
370
|
+
onCancellableChange = () => {},
|
|
232
371
|
dbg = () => {},
|
|
233
372
|
} = {}) {
|
|
234
373
|
let client = null;
|
|
235
374
|
let session = null;
|
|
236
375
|
let sdkPaths = null;
|
|
237
|
-
|
|
376
|
+
// The single owner of "what model/effort is the live session CONFIRMED to be
|
|
377
|
+
// on" (audit #9/#13): tracks the last confirmed pair, caches the per-session
|
|
378
|
+
// model catalog for effort validation, and observes `session.model_change`
|
|
379
|
+
// drains for deferred switches. `appliedModel()` is the runner-side read.
|
|
380
|
+
const modelSwitch = createCopilotModelSwitcher({ switchTimeoutMs: modelSwitchTimeoutMs, dbg });
|
|
381
|
+
const appliedModel = () => modelSwitch.current().model;
|
|
238
382
|
let activeTurn = null;
|
|
383
|
+
// Turns that have SETTLED but whose relay publishes have not all landed yet.
|
|
384
|
+
// Ownership of a turn is split in two: `activeTurn` is runtime-EVENT
|
|
385
|
+
// ownership (which turn the session callback feeds), this set is QUEUE-ROW
|
|
386
|
+
// ownership (which rows the heartbeat must keep claiming and the crash guard
|
|
387
|
+
// must requeue). The split exists because the runtime does not wait for the
|
|
388
|
+
// relay: a fast follow-on continuation can open, run and terminate while the
|
|
389
|
+
// previous turn's `/api/response` is still in flight, and holding the event
|
|
390
|
+
// stream hostage to that POST is exactly how a whole continuation vanished
|
|
391
|
+
// (audit #5). A Set, not a slot: the follow-on turn's own publish can block
|
|
392
|
+
// too, so several turns can be publishing at once.
|
|
393
|
+
const publishingTurns = new Set();
|
|
239
394
|
let lifecycleTimer = null;
|
|
240
395
|
let lastActivityAt = Date.now();
|
|
241
396
|
let lastTurnUsage = null;
|
|
@@ -247,15 +402,115 @@ export function createCopilotSdkSessionRunner({
|
|
|
247
402
|
// The in-flight ingest POST, exposed only as a test seam. Never awaited by
|
|
248
403
|
// the turn path — that is the entire point of it.
|
|
249
404
|
let usagePostChain = Promise.resolve();
|
|
405
|
+
// The last catalog content this worker POSTed to `/api/models/snapshot`,
|
|
406
|
+
// as a serialized signature. Identity of CONTENT, not of session: an idle
|
|
407
|
+
// shutdown and resume onto the same catalog must not re-publish it.
|
|
408
|
+
let lastModelSnapshotSignature = '';
|
|
409
|
+
// The in-flight snapshot POST — a chain like `usagePostChain`, and equally a
|
|
410
|
+
// test seam only: the turn path never awaits a snapshot.
|
|
411
|
+
let modelSnapshotChain = Promise.resolve();
|
|
250
412
|
let starting = null;
|
|
251
413
|
let disposed = false;
|
|
252
414
|
let detachRuntimeExit = () => {};
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
//
|
|
258
|
-
|
|
415
|
+
// A compaction in progress (`session.compaction_start` seen, no `_complete`
|
|
416
|
+
// yet). Holds steering: a message pushed now would land inside the
|
|
417
|
+
// compaction's replay. `compactingSince` backs the stale cap.
|
|
418
|
+
let compactingSince = 0;
|
|
419
|
+
// The last readiness value reported to `onDeliveryReadinessChange`, so the
|
|
420
|
+
// link only hears about flips.
|
|
421
|
+
let lastReportedDeliveryReady = null;
|
|
422
|
+
// Rows whose prompt the runtime consumed and whose settle marker is being
|
|
423
|
+
// saved (id → { message, variant }). Reported by the heartbeat WITH a
|
|
424
|
+
// terminal error until the publish lands, so recovery can never re-run them
|
|
425
|
+
// (see publishSettleMarker).
|
|
426
|
+
const settlingMessages = new Map();
|
|
427
|
+
// Settle markers that could not be posted even terminally; the heartbeat
|
|
428
|
+
// hands them to the relay to fail (id → terminalError).
|
|
429
|
+
const settleFailedTerminals = new Map();
|
|
430
|
+
// Per-row chains of `/api/queue-consumed` POSTs, so a mark and a later
|
|
431
|
+
// un-mark for the same row reach the relay in order.
|
|
432
|
+
const consumedMarkChains = new Map();
|
|
433
|
+
// The runtime's queued lane as last read from `rpc.queue.pendingItems`:
|
|
434
|
+
// runtime message id → stable queue item id. What un-steer needs, and what
|
|
435
|
+
// decides which pushed rows the client may still cancel.
|
|
436
|
+
let queuedLane = new Map();
|
|
437
|
+
// The rest of the last queue snapshot: how many immediate messages wait in
|
|
438
|
+
// the runtime's steering lane (entries not yet folded into the running
|
|
439
|
+
// turn), and how many items the queued lane holds. The steering lane has no
|
|
440
|
+
// ids — only its newest entry can be removed (`queue.removeMostRecent`,
|
|
441
|
+
// LIFO across both lanes; probe 2026-09-25) — so these decide whether our
|
|
442
|
+
// newest steer is still recallable.
|
|
443
|
+
let waitingSteers = 0;
|
|
444
|
+
let queuedItemCount = 0;
|
|
445
|
+
// Guards for the LIFO recall (removeMostRecent takes the runtime's NEWEST
|
|
446
|
+
// waiting message, whoever sent it):
|
|
447
|
+
// - `steerSendsInFlight`: steer sends whose `send()` has not resolved yet.
|
|
448
|
+
// The runtime may already count such a message while this worker cannot
|
|
449
|
+
// yet name it, so no "newest" recall is attempted while any is in flight.
|
|
450
|
+
// - `steerSendSeq`: send order across every owned turn, so "our newest
|
|
451
|
+
// unconsumed steer" is decided by when it was sent, not by which turn
|
|
452
|
+
// object happens to hold it.
|
|
453
|
+
// - `unsteerChain`: un-steers run one at a time (each re-reads the queue).
|
|
454
|
+
// Steer sends never wait on an un-steer; the recall check and its remove
|
|
455
|
+
// request run in the same tick, so no send can be issued between them.
|
|
456
|
+
//
|
|
457
|
+
// What else removeMostRecent can take (live probe, runtime 1.0.85,
|
|
458
|
+
// 2026-09-26): only user-facing sends. The runtime's own notifications
|
|
459
|
+
// (agent_idle / agent_completed / shell_completed) never show in either
|
|
460
|
+
// lane — with one pending and nothing of ours queued the remove answered
|
|
461
|
+
// `removed:false`, and the notification was still delivered after a remove
|
|
462
|
+
// that took our steer. A `source:"system"` send is hidden from pendingItems
|
|
463
|
+
// and skipped (the remove took the OLDER user steer; the system one still
|
|
464
|
+
// ran). An `agent-*`-sourced send is listed and removable, but the
|
|
465
|
+
// runtime's inter-agent prompts never passed through the main lanes. So the
|
|
466
|
+
// read-then-remove window cannot drop a runtime message; what it can still
|
|
467
|
+
// hit is a message another client of the session pushed in that window,
|
|
468
|
+
// which the two guards below detect:
|
|
469
|
+
// - `recallWatches`: runtime id → { taken } for a recall in progress. A
|
|
470
|
+
// root `user.message` naming it sets `taken` from the event callback
|
|
471
|
+
// itself — the SDK dispatches a notification before it resolves a later
|
|
472
|
+
// RPC answer, whereas `entry.consumed` is set on the dispatch chain and
|
|
473
|
+
// can lag behind a slow publish.
|
|
474
|
+
// - `recalledRuntimeIds`: runtime id → row id of every prompt pulled back
|
|
475
|
+
// and cancelled. One must never run; if it does, the remove took
|
|
476
|
+
// another message and the row was cancelled wrongly — logged loudly.
|
|
477
|
+
let steerSendsInFlight = 0;
|
|
478
|
+
let lastCancellableSignature = '';
|
|
479
|
+
let steerSendSeq = 0;
|
|
480
|
+
let unsteerChain = Promise.resolve();
|
|
481
|
+
const recallWatches = new Map();
|
|
482
|
+
const recalledRuntimeIds = new Map();
|
|
483
|
+
const MAX_RECALLED_RUNTIME_IDS = 200;
|
|
484
|
+
/** How long an un-steer may wait on the runtime before it gives up. */
|
|
485
|
+
const UNSTEER_TIMEOUT_MS = 5_000;
|
|
486
|
+
let queueLaneTimer = null;
|
|
487
|
+
let queueLaneRefreshChain = Promise.resolve();
|
|
488
|
+
// Whether the session's `rpc.queue` / `rpc.interruptMainTurn` / `rpc.tasks`
|
|
489
|
+
// are present. Probed per session (the runtime auto-updates under the relay
|
|
490
|
+
// and every one of these is @experimental); a missing surface degrades to
|
|
491
|
+
// the previous behaviour, never to an error.
|
|
492
|
+
let sessionRpc = { queue: false, interruptMainTurn: false, tasks: false };
|
|
493
|
+
// Set once a runtime refused the relay's `ask_user` override: every later
|
|
494
|
+
// session is built without it (the built-in + wording heuristic remain).
|
|
495
|
+
let askUserOverrideRejected = false;
|
|
496
|
+
// The runtime's background task registry as last read from
|
|
497
|
+
// `rpc.tasks.list()` (id → TaskInfo), and what the event stream adds per
|
|
498
|
+
// agent that the registry does not carry: the live tool call, a running
|
|
499
|
+
// token count, the model announced at spawn. Both feed the task panel
|
|
500
|
+
// cards; the registry alone decides liveness (the lifecycle pin).
|
|
501
|
+
let backgroundTasks = new Map();
|
|
502
|
+
const agentDetails = new Map();
|
|
503
|
+
let taskRefreshTimer = null;
|
|
504
|
+
let taskRefreshChain = Promise.resolve();
|
|
505
|
+
let lastTaskRefreshAt = 0;
|
|
506
|
+
let taskPublishTimer = null;
|
|
507
|
+
let lastPublishedTasksSignature = '';
|
|
508
|
+
// Cap expiry is bounded: a task whose cancel the runtime refuses (or that
|
|
509
|
+
// keeps failing) this many times is abandoned — dropped from the live set so
|
|
510
|
+
// it stops pinning the runtime — rather than held forever.
|
|
511
|
+
const taskCancelRefusals = new Map();
|
|
512
|
+
const abandonedTasks = new Set();
|
|
513
|
+
const MAX_TASK_CANCEL_REFUSALS = 3;
|
|
259
514
|
// Blocking handlers currently waiting on a human (`ask_user`, an ask-mode
|
|
260
515
|
// tool approval). The runtime emits NO events while blocked in one, and the
|
|
261
516
|
// question timeout is 8 hours against a 120s stall ceiling — so without this
|
|
@@ -281,10 +536,11 @@ export function createCopilotSdkSessionRunner({
|
|
|
281
536
|
// continuation opening, or by the grace expiring on a runtime that decided it
|
|
282
537
|
// had nothing to say.
|
|
283
538
|
let continuationDueSince = 0;
|
|
284
|
-
// Transcript lines produced between turns (a settled shell's
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
// makes.
|
|
539
|
+
// Transcript lines produced between turns (a settled shell's or background
|
|
540
|
+
// agent's notification), as `{ text, reportsFailure }`. They belong to the
|
|
541
|
+
// turn they trigger, so they are carried into it rather than dropped — the
|
|
542
|
+
// same trade the Claude worker's `pendingActivities` makes. A line that
|
|
543
|
+
// reports a failure keeps the row it lands on (see `showsWork`).
|
|
288
544
|
let pendingActivities = [];
|
|
289
545
|
// The relay mode of the last delivered turn. A self-initiated turn has no
|
|
290
546
|
// delivery to read a mode off, and it is a continuation OF that turn's work,
|
|
@@ -292,18 +548,85 @@ export function createCopilotSdkSessionRunner({
|
|
|
292
548
|
// plan-board gating behaving the same either side of a background wait.
|
|
293
549
|
let lastRelayMode = 'agent';
|
|
294
550
|
|
|
551
|
+
/**
|
|
552
|
+
* The turn a human request belongs to, opened on demand when none is live.
|
|
553
|
+
*
|
|
554
|
+
* A card needs a `processing` row (`/api/relay-question` 409s otherwise),
|
|
555
|
+
* and a background agent can ask long after the turn that spawned it settled
|
|
556
|
+
* on `assistant.idle`. Nothing else opens a row for that question, so it
|
|
557
|
+
* gets a continuation of its own — the Claude worker opens one before an
|
|
558
|
+
* off-turn `AskUserQuestion` for the same reason. `humanOrigin`: unless root
|
|
559
|
+
* work joins it, it closes itself once its last card is done.
|
|
560
|
+
*/
|
|
561
|
+
function ensureInteractiveTurn() {
|
|
562
|
+
if (activeTurn || !session || disposed) return activeTurn;
|
|
563
|
+
const turn = openContinuationTurn();
|
|
564
|
+
turn.humanOrigin = true;
|
|
565
|
+
return turn;
|
|
566
|
+
}
|
|
567
|
+
|
|
295
568
|
async function whileAwaitingHuman(run) {
|
|
569
|
+
// Captured once: the card belongs to this turn's row even when another
|
|
570
|
+
// turn owns the event stream by the time it is created or answered.
|
|
571
|
+
const turn = ensureInteractiveTurn();
|
|
296
572
|
pendingHumanRequests += 1;
|
|
573
|
+
if (turn) {
|
|
574
|
+
if (turn.humanRequests === 0) turn.humansIdle = new Promise((resolve) => { turn.resolveHumansIdle = resolve; });
|
|
575
|
+
turn.humanRequests += 1;
|
|
576
|
+
turn.askedHuman = true;
|
|
577
|
+
}
|
|
578
|
+
// An open card holds steering: the relay's readiness is withdrawn at once.
|
|
579
|
+
syncDeliveryReadiness();
|
|
297
580
|
try {
|
|
298
|
-
return await run();
|
|
581
|
+
return await run(turn);
|
|
299
582
|
} finally {
|
|
300
583
|
pendingHumanRequests -= 1;
|
|
584
|
+
if (turn) {
|
|
585
|
+
turn.humanRequests -= 1;
|
|
586
|
+
if (turn.humanRequests === 0) {
|
|
587
|
+
turn.resolveHumansIdle();
|
|
588
|
+
// A row opened only for questions: no terminator is coming for it.
|
|
589
|
+
if (turn.humanOrigin && !turn.rootWork && !turn.settled) {
|
|
590
|
+
turn.result = { subtype: 'completed', text: BACKGROUND_QUESTION_NOTE };
|
|
591
|
+
turn.settle();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
301
595
|
// The clock restarts from the answer, not from before the wait.
|
|
302
596
|
touch();
|
|
303
597
|
activeTurn?.armStall?.();
|
|
598
|
+
// The answer re-arms the relay immediately, so a message queued while
|
|
599
|
+
// the card was open steers into the resumed turn within one round trip.
|
|
600
|
+
syncDeliveryReadiness();
|
|
304
601
|
}
|
|
305
602
|
}
|
|
306
603
|
|
|
604
|
+
/**
|
|
605
|
+
* Hold an interactive callback until its turn's queue row exists.
|
|
606
|
+
*
|
|
607
|
+
* A continuation becomes the active turn synchronously but gets its row
|
|
608
|
+
* asynchronously, and the runtime can block on `user_input.requested` (or an
|
|
609
|
+
* ask-mode approval) in that gap — the question bridge would then see no
|
|
610
|
+
* row and degrade to an unsupported answer / local rejection for a card
|
|
611
|
+
* that was milliseconds from having a real row id. Bounded by the
|
|
612
|
+
* registration timeout (the registration itself can hang on a dead relay);
|
|
613
|
+
* on expiry — or on a registration that gave up (`rowReady` → false) — the
|
|
614
|
+
* caller proceeds and degrades exactly as before. Delivered turns are
|
|
615
|
+
* registered from birth and skip straight through.
|
|
616
|
+
*/
|
|
617
|
+
async function awaitInteractiveRow(turn) {
|
|
618
|
+
if (!turn || turn.registered || !turn.rowReady) return;
|
|
619
|
+
let timer = null;
|
|
620
|
+
await Promise.race([
|
|
621
|
+
turn.rowReady,
|
|
622
|
+
new Promise((resolve) => {
|
|
623
|
+
timer = setTimeout(resolve, continuationRegistrationTimeoutMs);
|
|
624
|
+
timer.unref?.();
|
|
625
|
+
}),
|
|
626
|
+
]);
|
|
627
|
+
clearTimeout(timer);
|
|
628
|
+
}
|
|
629
|
+
|
|
307
630
|
// The relay question bridge serves `ask_user` and (in ask mode) tool
|
|
308
631
|
// approvals. `getActiveMessage` must resolve to the row that is CURRENTLY
|
|
309
632
|
// `processing`, because `/api/relay-question` 409s otherwise.
|
|
@@ -336,6 +659,32 @@ export function createCopilotSdkSessionRunner({
|
|
|
336
659
|
lastActivityAt = Date.now();
|
|
337
660
|
}
|
|
338
661
|
|
|
662
|
+
/**
|
|
663
|
+
* A turn's terminal event ends its runtime-event ownership IMMEDIATELY, while
|
|
664
|
+
* queue-row ownership persists until every publish has landed.
|
|
665
|
+
*
|
|
666
|
+
* Called from `settle`/`fail`, so it runs the instant the terminator is
|
|
667
|
+
* dispatched: from here on `routeEvent` sees no active turn and a genuinely
|
|
668
|
+
* new event can open the next continuation — it must not wait out a blocked
|
|
669
|
+
* `/api/response`. The row itself stays owned (heartbeat lease, crash-guard
|
|
670
|
+
* requeue) via `publishingTurns` until `releaseTurnOwnership`.
|
|
671
|
+
*/
|
|
672
|
+
function beginPublishing(turn) {
|
|
673
|
+
publishingTurns.add(turn);
|
|
674
|
+
if (activeTurn === turn) activeTurn = null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Every publish for the turn has landed (or terminally failed); nothing owns
|
|
679
|
+
* its rows any more. Guarded on identity: by the time a publish window
|
|
680
|
+
* closes, `activeTurn` may already belong to a NEWER turn, and clearing it
|
|
681
|
+
* unconditionally would strip that turn's heartbeat ownership mid-flight.
|
|
682
|
+
*/
|
|
683
|
+
function releaseTurnOwnership(turn) {
|
|
684
|
+
publishingTurns.delete(turn);
|
|
685
|
+
if (activeTurn === turn) activeTurn = null;
|
|
686
|
+
}
|
|
687
|
+
|
|
339
688
|
// ---------------------------------------------------------------- publish --
|
|
340
689
|
|
|
341
690
|
async function postActivity(message, text, subagentRunId = null) {
|
|
@@ -346,6 +695,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
346
695
|
mode: message.relayMode || 'agent',
|
|
347
696
|
text,
|
|
348
697
|
...(subagentRunId ? { subagentRunId } : {}),
|
|
698
|
+
...attemptFields(message),
|
|
349
699
|
}).catch(() => {});
|
|
350
700
|
}
|
|
351
701
|
|
|
@@ -367,6 +717,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
367
717
|
text: payload.text,
|
|
368
718
|
done: payload.done === true,
|
|
369
719
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
720
|
+
...attemptFields(message),
|
|
370
721
|
}).catch(() => {});
|
|
371
722
|
return;
|
|
372
723
|
}
|
|
@@ -379,6 +730,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
379
730
|
text: payload.text,
|
|
380
731
|
done: payload.done === true,
|
|
381
732
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
733
|
+
...attemptFields(message),
|
|
382
734
|
}).catch(() => {});
|
|
383
735
|
return;
|
|
384
736
|
}
|
|
@@ -397,6 +749,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
397
749
|
...(payload.parentSubagentId ? { parentSubagentId: payload.parentSubagentId } : {}),
|
|
398
750
|
...(payload.displayName ? { displayName: payload.displayName } : {}),
|
|
399
751
|
status: payload.status,
|
|
752
|
+
...attemptFields(message),
|
|
400
753
|
}).catch(() => {});
|
|
401
754
|
}
|
|
402
755
|
}
|
|
@@ -427,19 +780,89 @@ export function createCopilotSdkSessionRunner({
|
|
|
427
780
|
}
|
|
428
781
|
|
|
429
782
|
/**
|
|
430
|
-
* Publish an action
|
|
783
|
+
* Publish an action onto the row that owns the runtime's current run, or
|
|
784
|
+
* hold it if the turn has no queue row yet.
|
|
431
785
|
*
|
|
432
786
|
* The single gate every producer goes through. A continuation's row is
|
|
433
787
|
* created asynchronously, and a POST carrying `messageId: null` is not merely
|
|
434
788
|
* useless — the relay's activity/stream routes key on it, so it would be
|
|
435
789
|
* attributed to nothing at all.
|
|
790
|
+
*
|
|
791
|
+
* One turn can answer several rows in sequence (the primary, then a message
|
|
792
|
+
* pushed mid-turn that the runtime ran as its own `delivery:"queued"` run).
|
|
793
|
+
* Root-level traffic — stream, thoughts, activity, subagent lanes — goes to
|
|
794
|
+
* whichever row owns the run in progress (`turn.currentOwner`); the stream
|
|
795
|
+
* text is re-cut to that row's own segments so a second run's prose never
|
|
796
|
+
* appears under the first row.
|
|
436
797
|
*/
|
|
437
798
|
async function emitAction(turn, action) {
|
|
438
799
|
if (!turn.registered) {
|
|
439
800
|
turn.bufferedActions.push(action);
|
|
440
801
|
return;
|
|
441
802
|
}
|
|
442
|
-
await
|
|
803
|
+
await dispatchOwned(turn, action);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
async function dispatchOwned(turn, action) {
|
|
807
|
+
const owner = turn.currentOwner || turn.primaryEntry;
|
|
808
|
+
if (!owner || owner.settled) {
|
|
809
|
+
// Nothing left to publish into (every row this turn owned has settled);
|
|
810
|
+
// the text still lands in the runtime's own transcript.
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (showsWork(action)) owner.showedWork = true;
|
|
814
|
+
if (action.channel === 'stream' && !action.payload?.subagentRunId) {
|
|
815
|
+
// Re-cut to the owner's own segments, and gated against what THAT row
|
|
816
|
+
// last received (the normalizer gated against the whole interaction).
|
|
817
|
+
const text = textFor(turn, owner);
|
|
818
|
+
if (!shouldEmitStreamUpdate(text, owner.state.lastStreamedText)) return;
|
|
819
|
+
await dispatchAction(owner.message, { channel: 'stream', payload: { ...action.payload, text } }, owner.state);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
await dispatchAction(owner.message, action, owner.state);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Which row a normalizer segment's text belongs to. A segment nobody
|
|
827
|
+
* claimed (text produced before any prompt boundary — the answer to a
|
|
828
|
+
* prompt carried over from a previous interaction, or a turn the runtime
|
|
829
|
+
* answered without echoing a `user.message` at all) belongs to the next
|
|
830
|
+
* claimed segment's row, failing that to the turn's own row.
|
|
831
|
+
*/
|
|
832
|
+
function segmentOwner(turn, index) {
|
|
833
|
+
const owners = turn.segmentOwners;
|
|
834
|
+
if (owners[index]) return owners[index];
|
|
835
|
+
for (let j = index + 1; j < owners.length; j += 1) {
|
|
836
|
+
if (owners[j]) return owners[j];
|
|
837
|
+
}
|
|
838
|
+
return turn.primaryEntry;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function textFor(turn, entry, segmentTexts = null) {
|
|
842
|
+
const count = Math.max(turn.normalizer.promptCount(), turn.segmentOwners.length);
|
|
843
|
+
const parts = [];
|
|
844
|
+
for (let index = 0; index < count; index += 1) {
|
|
845
|
+
if (segmentOwner(turn, index) !== entry) continue;
|
|
846
|
+
const text = String(segmentTexts?.[index] ?? turn.normalizer.segmentText(index) ?? '');
|
|
847
|
+
if (text.trim()) parts.push(text);
|
|
848
|
+
}
|
|
849
|
+
return parts.join('\n\n');
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* The final reply text for `entry`: its segments, with the task-completion
|
|
854
|
+
* summary taking precedence on the last one (the normalizer's
|
|
855
|
+
* `result.segmentTexts` already applies that rule; before this the runner
|
|
856
|
+
* read `segmentText` directly and lost the summary — the "Cha…" fragment
|
|
857
|
+
* case of 2026-09-12). A turn that opened no segment at all falls back to
|
|
858
|
+
* the whole composed text for its own row, which is strictly better than
|
|
859
|
+
* publishing nothing.
|
|
860
|
+
*/
|
|
861
|
+
function finalTextFor(turn, entry, result) {
|
|
862
|
+
const segmentTexts = Array.isArray(result?.segmentTexts) ? result.segmentTexts : null;
|
|
863
|
+
const text = textFor(turn, entry, segmentTexts).trim();
|
|
864
|
+
if (text || entry !== turn.primaryEntry) return text;
|
|
865
|
+
return String(result?.text || entry.state.lastStreamedText || '').trim();
|
|
443
866
|
}
|
|
444
867
|
|
|
445
868
|
async function publishFinalStream(message, text) {
|
|
@@ -449,21 +872,58 @@ export function createCopilotSdkSessionRunner({
|
|
|
449
872
|
mode: message.relayMode || 'agent',
|
|
450
873
|
text: String(text || ''),
|
|
451
874
|
done: true,
|
|
875
|
+
...attemptFields(message),
|
|
452
876
|
}).catch(() => {});
|
|
453
877
|
}
|
|
454
878
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
879
|
+
/**
|
|
880
|
+
* Publish a row's outcome. Returns 'published' | 'stale' | 'failed' |
|
|
881
|
+
* 'requeued' so the settle paths can tell a landed marker from a lost one.
|
|
882
|
+
*
|
|
883
|
+
* `kind` ('folded' | 'stopped') stamps the settle stubs of a steered row the
|
|
884
|
+
* runtime consumed without answering on its own row; `consumedSteerIds`
|
|
885
|
+
* names the steers folded into the turn this response finishes, so the
|
|
886
|
+
* relay marks them consumed in the same fenced write. A response carrying a
|
|
887
|
+
* kind is never requeued on failure (the prompt was consumed — re-running it
|
|
888
|
+
* is the one thing that must not happen); `requeueOnFailure: false` opts an
|
|
889
|
+
* ordinary response out of the requeue too.
|
|
890
|
+
*/
|
|
891
|
+
async function publishResponse(message, {
|
|
892
|
+
text, model, terminalError = null, modelOrigin, kind = null, consumedSteerIds = null,
|
|
893
|
+
requeueOnFailure = true,
|
|
894
|
+
}) {
|
|
895
|
+
const consumed = Array.isArray(consumedSteerIds)
|
|
896
|
+
? consumedSteerIds
|
|
897
|
+
.map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
|
|
898
|
+
.filter((entry) => entry.id)
|
|
899
|
+
: [];
|
|
900
|
+
try {
|
|
901
|
+
await api('POST', '/api/response', {
|
|
902
|
+
messageId: message.id,
|
|
903
|
+
conversationId: message.conversationId,
|
|
904
|
+
text: String(text || ''),
|
|
905
|
+
model: model || null,
|
|
906
|
+
modelOrigin: modelOrigin
|
|
907
|
+
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
908
|
+
...(terminalError ? { terminalError } : {}),
|
|
909
|
+
...(kind ? { kind } : {}),
|
|
910
|
+
...(consumed.length ? { consumedSteerIds: consumed } : {}),
|
|
911
|
+
...attemptFields(message),
|
|
912
|
+
});
|
|
913
|
+
return 'published';
|
|
914
|
+
} catch (error) {
|
|
915
|
+
// A stale_attempt 409 means the row already moved on to another attempt
|
|
916
|
+
// (requeued and re-delivered); the requeue would 409 the same way, and
|
|
917
|
+
// the newer attempt owes the row its answer — drop this one.
|
|
918
|
+
if (isStaleAttemptError(error)) {
|
|
919
|
+
dbg('response refused as stale_attempt; dropping', message.id);
|
|
920
|
+
return 'stale';
|
|
921
|
+
}
|
|
922
|
+
if (kind || !requeueOnFailure) return 'failed';
|
|
923
|
+
const requeued = await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) })
|
|
924
|
+
.then(() => true, () => false);
|
|
925
|
+
return requeued ? 'requeued' : 'failed';
|
|
926
|
+
}
|
|
467
927
|
}
|
|
468
928
|
|
|
469
929
|
function terminalErrorRecord(message, classified) {
|
|
@@ -477,6 +937,117 @@ export function createCopilotSdkSessionRunner({
|
|
|
477
937
|
};
|
|
478
938
|
}
|
|
479
939
|
|
|
940
|
+
// ------------------------------------------------- at-most-once settling --
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Claim a consumed row as settling — synchronously, BEFORE anything stops
|
|
944
|
+
* reporting it as live, so no heartbeat can see it unowned. `variant` picks
|
|
945
|
+
* the terminal wording should its settle never land.
|
|
946
|
+
*/
|
|
947
|
+
function claimSettling(message, variant = 'folded') {
|
|
948
|
+
const id = String(message?.id || '').trim();
|
|
949
|
+
if (id && !settlingMessages.has(id)) settlingMessages.set(id, { message, variant });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** Release a settled row — unless it was handed to the relay to fail. */
|
|
953
|
+
function releaseSettling(id) {
|
|
954
|
+
const key = String(id || '').trim();
|
|
955
|
+
if (!settleFailedTerminals.has(key)) settlingMessages.delete(key);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Mark rows whose prompt the runtime has folded into a running turn as
|
|
960
|
+
* consumed on the relay (the moment `user.message{delivery:"steering"}`
|
|
961
|
+
* confirms it). Best-effort: the worker's own settle still owns these rows
|
|
962
|
+
* while it lives; the mark only protects them when it does not — a crash
|
|
963
|
+
* before the settle marker lands must fail the row, never re-run it.
|
|
964
|
+
*/
|
|
965
|
+
function markConsumed(conversationId, entries = [], { consumed = true } = {}) {
|
|
966
|
+
const rows = entries
|
|
967
|
+
.map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
|
|
968
|
+
.filter((entry) => entry.id);
|
|
969
|
+
if (!rows.length) return Promise.resolve();
|
|
970
|
+
const previous = Promise.allSettled(rows.map((row) => consumedMarkChains.get(row.id)).filter(Boolean));
|
|
971
|
+
const post = previous.then(() => api('POST', '/api/queue-consumed', {
|
|
972
|
+
conversationId: String(conversationId || sdkSessionId || ''),
|
|
973
|
+
entries: rows,
|
|
974
|
+
...(consumed ? {} : { consumed: false }),
|
|
975
|
+
})).catch((error) => {
|
|
976
|
+
dbg('consumed mark failed', rows.map((entry) => entry.id).join(','), error?.message || String(error));
|
|
977
|
+
});
|
|
978
|
+
for (const row of rows) consumedMarkChains.set(row.id, post);
|
|
979
|
+
post.then(() => {
|
|
980
|
+
for (const row of rows) if (consumedMarkChains.get(row.id) === post) consumedMarkChains.delete(row.id);
|
|
981
|
+
});
|
|
982
|
+
return post;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** Heartbeat payload: rows whose terminal failure the relay must post. */
|
|
986
|
+
function getSettleFailed() {
|
|
987
|
+
return [...settleFailedTerminals.entries()].map(([id, terminalError]) => ({
|
|
988
|
+
id,
|
|
989
|
+
attemptId: settlingMessages.get(id)?.message?.attemptId || null,
|
|
990
|
+
terminalError,
|
|
991
|
+
}));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function acknowledgeSettleFailed(ids = []) {
|
|
995
|
+
for (const id of ids) {
|
|
996
|
+
const key = String(id || '').trim();
|
|
997
|
+
if (!settleFailedTerminals.delete(key)) continue;
|
|
998
|
+
settlingMessages.delete(key);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const sleep = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.(); });
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Save the settle marker of a steered message the runtime already consumed
|
|
1006
|
+
* — at most once. The row stays in `settlingMessages`, and so in every
|
|
1007
|
+
* heartbeat, until the relay commits the marker. A marker that cannot be
|
|
1008
|
+
* saved within the retry budget ends as a terminal failure — never a
|
|
1009
|
+
* requeue — and if even that cannot be posted, the heartbeat hands the row
|
|
1010
|
+
* to the relay to fail. Ported from the Claude worker; same guarantees.
|
|
1011
|
+
*/
|
|
1012
|
+
async function publishSettleMarker(message, { text, model, kind, variant = 'folded' }) {
|
|
1013
|
+
const id = String(message?.id || '').trim();
|
|
1014
|
+
if (!id) return;
|
|
1015
|
+
claimSettling(message, variant);
|
|
1016
|
+
try {
|
|
1017
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1018
|
+
const outcome = await publishResponse(message, { text, model, kind });
|
|
1019
|
+
if (outcome !== 'failed') return;
|
|
1020
|
+
if (attempt >= settleRetryDelaysMs.length) break;
|
|
1021
|
+
await sleep(settleRetryDelaysMs[attempt]);
|
|
1022
|
+
}
|
|
1023
|
+
dbg('settle marker could not be saved; failing the row terminally', id);
|
|
1024
|
+
const terminalError = buildSteerSettleFailure(message, { variant, agentLabel: SETTLE_AGENT_LABEL });
|
|
1025
|
+
const retryMs = Math.max(100, Number(settleRetryDelaysMs.at(-1)) || 0);
|
|
1026
|
+
for (let attempt = 0; attempt < Math.max(1, maxTerminalSettleAttempts); attempt += 1) {
|
|
1027
|
+
if (attempt) await sleep(retryMs);
|
|
1028
|
+
const outcome = await publishResponse(message, {
|
|
1029
|
+
text: terminalError.message,
|
|
1030
|
+
model,
|
|
1031
|
+
terminalError,
|
|
1032
|
+
requeueOnFailure: false,
|
|
1033
|
+
});
|
|
1034
|
+
if (outcome !== 'failed') return;
|
|
1035
|
+
// Second channel: the requeue route fails a row outright when handed
|
|
1036
|
+
// a terminal error.
|
|
1037
|
+
const failedViaRequeue = await api('POST', '/api/requeue', {
|
|
1038
|
+
messageId: id,
|
|
1039
|
+
terminalError,
|
|
1040
|
+
...attemptFields(message),
|
|
1041
|
+
}).then(() => true, (error) => isStaleAttemptError(error));
|
|
1042
|
+
if (failedViaRequeue) return;
|
|
1043
|
+
}
|
|
1044
|
+
dbg('terminal settle could not be posted; handing it to the heartbeat', id);
|
|
1045
|
+
settleFailedTerminals.set(id, terminalError);
|
|
1046
|
+
} finally {
|
|
1047
|
+
releaseSettling(id);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
480
1051
|
/**
|
|
481
1052
|
* Per-turn usage capture, recorded on the runner.
|
|
482
1053
|
*
|
|
@@ -491,7 +1062,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
491
1062
|
conversationId: message.conversationId,
|
|
492
1063
|
sdkSessionId,
|
|
493
1064
|
messageId: message.id,
|
|
494
|
-
model: result?.model || appliedModel || defaultModel || '',
|
|
1065
|
+
model: result?.model || appliedModel() || defaultModel || '',
|
|
495
1066
|
usage,
|
|
496
1067
|
contextUsage,
|
|
497
1068
|
capturedAt: new Date().toISOString(),
|
|
@@ -546,6 +1117,60 @@ export function createCopilotSdkSessionRunner({
|
|
|
546
1117
|
.catch((error) => { dbg('usage ingest failed', error?.message || String(error)); });
|
|
547
1118
|
}
|
|
548
1119
|
|
|
1120
|
+
/**
|
|
1121
|
+
* Publish the session's model catalog to `/api/models/snapshot` (Phase 5A:
|
|
1122
|
+
* with the extension retired, worker snapshots and the server-side discovery
|
|
1123
|
+
* service are what keep the relay's catalog populated).
|
|
1124
|
+
*
|
|
1125
|
+
* Fire-and-forget on a chain, exactly like `postTurnUsage`: a catalog is
|
|
1126
|
+
* never worth a millisecond of a turn, and the failure mode is "the relay
|
|
1127
|
+
* keeps its previous catalog" — swallowed after a debug line. The entries
|
|
1128
|
+
* come from the switcher's cached per-session `rpc.model.list()`, so the
|
|
1129
|
+
* common case adds zero RPCs; an unchanged catalog is deduped by content
|
|
1130
|
+
* signature rather than re-POSTed on every resume.
|
|
1131
|
+
*
|
|
1132
|
+
* BYOK sessions never publish: their list is the OpenAI-compatible
|
|
1133
|
+
* endpoint's per-key lineup, not the Copilot catalog, and a snapshot from
|
|
1134
|
+
* one would overwrite the relay-wide picker with it.
|
|
1135
|
+
*/
|
|
1136
|
+
function publishModelSnapshot(reason) {
|
|
1137
|
+
if (byokProvider || !session) return;
|
|
1138
|
+
const target = session;
|
|
1139
|
+
modelSnapshotChain = modelSnapshotChain
|
|
1140
|
+
.then(async () => {
|
|
1141
|
+
const entries = await modelSwitch.catalogEntries(target);
|
|
1142
|
+
const descriptors = extractModelDescriptors(entries);
|
|
1143
|
+
// An empty list is the runtime refusing to answer, not an empty
|
|
1144
|
+
// catalog — publishing it would only blank the pickers' metadata.
|
|
1145
|
+
if (!descriptors.length) return;
|
|
1146
|
+
// The shared builder decides every per-model metadata field (vendor,
|
|
1147
|
+
// picker category, real context window, the runtime's own effort
|
|
1148
|
+
// list, ...) so this raw-CAPI list and the discovery service's typed
|
|
1149
|
+
// list publish identical metadata for the same model.
|
|
1150
|
+
const { models, contextLimitsByModel, modelMetadataByModel } = buildModelSnapshotFields(descriptors);
|
|
1151
|
+
const currentModel = appliedModel() || defaultModel || null;
|
|
1152
|
+
const payload = {
|
|
1153
|
+
// Same field set the extension and the standalone relay publish;
|
|
1154
|
+
// the route reads exactly these seven keys and nothing else.
|
|
1155
|
+
source: `copilot-sdk-worker:${reason}`,
|
|
1156
|
+
models,
|
|
1157
|
+
contextLimitsByModel,
|
|
1158
|
+
modelMetadataByModel,
|
|
1159
|
+
currentModel,
|
|
1160
|
+
defaultModel: currentModel || models[0] || null,
|
|
1161
|
+
error: null,
|
|
1162
|
+
};
|
|
1163
|
+
const signature = JSON.stringify([models, contextLimitsByModel, modelMetadataByModel, currentModel]);
|
|
1164
|
+
if (signature === lastModelSnapshotSignature) return;
|
|
1165
|
+
await api('POST', '/api/models/snapshot', payload);
|
|
1166
|
+
// Recorded only after the POST lands, so a transient relay failure
|
|
1167
|
+
// retries on the next trigger instead of being deduped away.
|
|
1168
|
+
lastModelSnapshotSignature = signature;
|
|
1169
|
+
dbg('model snapshot published', reason, `models=${models.length}`, `current=${currentModel || 'unknown'}`);
|
|
1170
|
+
})
|
|
1171
|
+
.catch((error) => { dbg('model snapshot publish failed', reason, error?.message || String(error)); });
|
|
1172
|
+
}
|
|
1173
|
+
|
|
549
1174
|
// ---------------------------------------------------------------- session --
|
|
550
1175
|
|
|
551
1176
|
function routeEvent(event) {
|
|
@@ -558,8 +1183,34 @@ export function createCopilotSdkSessionRunner({
|
|
|
558
1183
|
return;
|
|
559
1184
|
}
|
|
560
1185
|
touch();
|
|
1186
|
+
observeRecalls(event);
|
|
561
1187
|
observeBackgroundShells(event);
|
|
1188
|
+
observeBackgroundAgents(event);
|
|
1189
|
+
observeCompaction(event);
|
|
1190
|
+
observeQueueLane(event);
|
|
1191
|
+
// A background subagent's spawn emits a SESSION-level
|
|
1192
|
+
// `session.model_change{source:"agent"}` for the subagent's own model
|
|
1193
|
+
// (live-probed on 1.0.88). It is not the main thread's model: feeding it
|
|
1194
|
+
// to the switcher would confirm a pending switch with the wrong model and
|
|
1195
|
+
// move the catalog's currentModel.
|
|
1196
|
+
const agentModelChange = event?.type === 'session.model_change'
|
|
1197
|
+
&& String(event?.data?.source || '').trim().toLowerCase() === 'agent';
|
|
1198
|
+
if (!agentModelChange) {
|
|
1199
|
+
// Keeps the confirmed-model tracking truthful for switches this worker
|
|
1200
|
+
// did not ask for, and resolves the bounded wait on a deferred switch's
|
|
1201
|
+
// drain. Behind the replay gate on purpose: a historical
|
|
1202
|
+
// `session.model_change` must not confirm a pending switch.
|
|
1203
|
+
modelSwitch.observeEvent(event);
|
|
1204
|
+
// A LIVE model change moves the catalog's currentModel; replays cannot
|
|
1205
|
+
// get here (the gate above), so this cannot re-publish days-old state.
|
|
1206
|
+
if (event?.type === 'session.model_change') publishModelSnapshot('model-change');
|
|
1207
|
+
}
|
|
562
1208
|
let turn = activeTurn;
|
|
1209
|
+
if (!turn) {
|
|
1210
|
+
// A prompt this worker pushed whose run the runtime opened after its
|
|
1211
|
+
// turn had already settled is that row's own turn, not a continuation.
|
|
1212
|
+
turn = adoptOrphanedRun(event);
|
|
1213
|
+
}
|
|
563
1214
|
if (!turn) {
|
|
564
1215
|
// The runtime started work with no row open. Anything that is not the
|
|
565
1216
|
// START of work (terminators, connection bookkeeping) stays a no-op:
|
|
@@ -568,13 +1219,13 @@ export function createCopilotSdkSessionRunner({
|
|
|
568
1219
|
if (!isContinuationOpeningEvent(event)) return;
|
|
569
1220
|
turn = openContinuationTurn();
|
|
570
1221
|
}
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
1222
|
+
// The owner is captured HERE, synchronously, but settlement happens later
|
|
1223
|
+
// on the dispatch chain — so an event can be captured to a turn that is
|
|
1224
|
+
// settled by the time its link runs. `handleTurnEvent`'s settled guard
|
|
1225
|
+
// re-routes such an event (in arrival order, on the same chain) to
|
|
1226
|
+
// whatever now owns the stream, opening a continuation if the event is an
|
|
1227
|
+
// opener. A settled turn that is still publishing holds only its QUEUE
|
|
1228
|
+
// rows (`publishingTurns`); it never holds the event stream.
|
|
578
1229
|
dispatchChain = dispatchChain
|
|
579
1230
|
.then(() => handleTurnEvent(turn, event))
|
|
580
1231
|
.catch((error) => { dbg('event dispatch failed', error?.message || String(error)); });
|
|
@@ -587,6 +1238,407 @@ export function createCopilotSdkSessionRunner({
|
|
|
587
1238
|
* delivered turn (that is where the model calls `bash`) and SETTLE outside
|
|
588
1239
|
* one, which is the whole asymmetry this fix exists for.
|
|
589
1240
|
*/
|
|
1241
|
+
/**
|
|
1242
|
+
* Mirror the live detached-shell set into the relay's background-tasks
|
|
1243
|
+
* panel (REPLACE semantics, the same route the Claude worker publishes on),
|
|
1244
|
+
* so a runaway `npm test` in a detached shell is visible as a card instead
|
|
1245
|
+
* of only as tool rows. `stoppable: false` because the runtime exposes no
|
|
1246
|
+
* host-side shell stop (`stop_bash` is a tool the MODEL calls) — see
|
|
1247
|
+
* backgroundWorkHoldsRuntime. Advisory: a failed post never disturbs events.
|
|
1248
|
+
*/
|
|
1249
|
+
function publishBackgroundShellTasks() {
|
|
1250
|
+
publishBackgroundTasks({ immediate: true });
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** The panel rows for the shell tracker's set — the path without `rpc.tasks`. */
|
|
1254
|
+
function shellTrackerCards() {
|
|
1255
|
+
return backgroundShells.live().map((shell) => ({
|
|
1256
|
+
taskId: shell.shellId,
|
|
1257
|
+
taskType: 'local_bash',
|
|
1258
|
+
description: shell.description || 'Detached shell',
|
|
1259
|
+
startedAt: shell.startedAt || null,
|
|
1260
|
+
stoppable: false,
|
|
1261
|
+
}));
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/**
|
|
1265
|
+
* The panel rows for the runtime's task registry: background agents and
|
|
1266
|
+
* shells still running, each with what the events added. `stoppable` is
|
|
1267
|
+
* true because `rpc.tasks.cancel` is present whenever this path is taken.
|
|
1268
|
+
*/
|
|
1269
|
+
function registryCards() {
|
|
1270
|
+
const cards = [];
|
|
1271
|
+
for (const task of backgroundTasks.values()) {
|
|
1272
|
+
if (task.status !== 'running') continue;
|
|
1273
|
+
const startedAt = Date.parse(String(task.startedAt || '')) || null;
|
|
1274
|
+
if (task.type === 'shell') {
|
|
1275
|
+
cards.push({
|
|
1276
|
+
taskId: task.id,
|
|
1277
|
+
taskType: 'local_bash',
|
|
1278
|
+
description: task.description || task.command || 'Background shell',
|
|
1279
|
+
startedAt,
|
|
1280
|
+
stoppable: true,
|
|
1281
|
+
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1282
|
+
});
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
if (task.type !== 'agent') continue;
|
|
1286
|
+
const detail = agentDetails.get(task.id) || {};
|
|
1287
|
+
const model = task.resolvedModel || task.model || detail.model || '';
|
|
1288
|
+
const inherited = !model;
|
|
1289
|
+
cards.push({
|
|
1290
|
+
taskId: task.id,
|
|
1291
|
+
taskType: 'local_agent',
|
|
1292
|
+
description: task.description || task.displayName || detail.displayName || 'Background agent',
|
|
1293
|
+
startedAt: startedAt || detail.startedAt || null,
|
|
1294
|
+
stoppable: true,
|
|
1295
|
+
subagentType: task.agentType || detail.agentType || null,
|
|
1296
|
+
model: model || appliedModel() || defaultModel || null,
|
|
1297
|
+
modelInherited: inherited,
|
|
1298
|
+
...(detail.lastToolCall ? { lastToolCall: detail.lastToolCall } : {}),
|
|
1299
|
+
...(Number.isFinite(detail.totalTokens) && detail.totalTokens > 0 ? { totalTokens: detail.totalTokens } : {}),
|
|
1300
|
+
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
return cards;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Mirror the live background work into the relay's task panel (REPLACE
|
|
1308
|
+
* semantics, the same route the Claude worker publishes on). Set changes
|
|
1309
|
+
* publish at once; enrichment-only changes (a tool call, tokens) ride a
|
|
1310
|
+
* trailing throttle. Advisory: a failed post never disturbs events.
|
|
1311
|
+
*/
|
|
1312
|
+
function publishBackgroundTasks({ immediate = false } = {}) {
|
|
1313
|
+
const run = () => {
|
|
1314
|
+
if (taskPublishTimer) {
|
|
1315
|
+
clearTimeout(taskPublishTimer);
|
|
1316
|
+
taskPublishTimer = null;
|
|
1317
|
+
}
|
|
1318
|
+
const tasks = sessionRpc.tasks ? registryCards() : shellTrackerCards();
|
|
1319
|
+
const signature = JSON.stringify(tasks);
|
|
1320
|
+
if (signature === lastPublishedTasksSignature) return;
|
|
1321
|
+
lastPublishedTasksSignature = signature;
|
|
1322
|
+
api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks })
|
|
1323
|
+
.catch((error) => dbg('background task publish failed', error?.message || String(error)));
|
|
1324
|
+
};
|
|
1325
|
+
if (immediate || !(taskPublishThrottleMs > 0)) {
|
|
1326
|
+
run();
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (taskPublishTimer) return;
|
|
1330
|
+
taskPublishTimer = setTimeout(run, taskPublishThrottleMs);
|
|
1331
|
+
taskPublishTimer.unref?.();
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// ------------------------------------------------------- background tasks --
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* What the event stream knows about background agents that the registry
|
|
1338
|
+
* does not: spawn details, the live tool call, a running token count, and
|
|
1339
|
+
* the completion heralds. `session.background_tasks_changed` itself is
|
|
1340
|
+
* empty and only says "re-read the registry".
|
|
1341
|
+
*/
|
|
1342
|
+
function observeBackgroundAgents(event) {
|
|
1343
|
+
const type = String(event?.type || '');
|
|
1344
|
+
const data = event?.data && typeof event.data === 'object' ? event.data : {};
|
|
1345
|
+
const agentId = String(event?.agentId || '').trim();
|
|
1346
|
+
if (type === 'session.background_tasks_changed') {
|
|
1347
|
+
scheduleTaskRefresh();
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (type === 'subagent.started' && String(data.executionMode || '').trim().toLowerCase() === 'background' && agentId) {
|
|
1351
|
+
// Kept for the life of the session (a finished agent's late events must
|
|
1352
|
+
// still be recognised as background), bounded so a long session cannot
|
|
1353
|
+
// grow it forever.
|
|
1354
|
+
while (agentDetails.size >= 500) agentDetails.delete(agentDetails.keys().next().value);
|
|
1355
|
+
agentDetails.set(agentId, {
|
|
1356
|
+
displayName: String(data.agentDisplayName || data.agentName || '').trim(),
|
|
1357
|
+
agentType: String(data.agentType || '').trim(),
|
|
1358
|
+
model: String(data.model || '').trim(),
|
|
1359
|
+
lastToolCall: '',
|
|
1360
|
+
totalTokens: 0,
|
|
1361
|
+
startedAt: Date.now(),
|
|
1362
|
+
});
|
|
1363
|
+
scheduleTaskRefresh();
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
const settledAgent = settledAgentFromNotification(event);
|
|
1367
|
+
if (settledAgent) {
|
|
1368
|
+
dbg('background agent settled', settledAgent.agentId, settledAgent.status);
|
|
1369
|
+
// The runtime is about to re-invoke the model off the back of this
|
|
1370
|
+
// (`read_agent` + a reply): pin the runtime for the continuation, and
|
|
1371
|
+
// carry a line into the turn it triggers when no turn is open to take
|
|
1372
|
+
// it. Same shape as a settled shell's herald.
|
|
1373
|
+
continuationDueSince = Date.now();
|
|
1374
|
+
const note = describeSettledAgent(settledAgent);
|
|
1375
|
+
if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
|
|
1376
|
+
pendingActivities.push({ text: note, reportsFailure: settledAgent.status === 'failed' });
|
|
1377
|
+
}
|
|
1378
|
+
scheduleTaskRefresh();
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (agentId && agentDetails.has(agentId)) {
|
|
1382
|
+
const detail = agentDetails.get(agentId);
|
|
1383
|
+
if (type === 'tool.execution_start') {
|
|
1384
|
+
detail.lastToolCall = formatToolActivityText(data.toolName, data.arguments);
|
|
1385
|
+
publishBackgroundTasks();
|
|
1386
|
+
} else if (type === 'assistant.usage') {
|
|
1387
|
+
const used = (Number(data.inputTokens) || 0) + (Number(data.outputTokens) || 0);
|
|
1388
|
+
if (used > 0) {
|
|
1389
|
+
detail.totalTokens += used;
|
|
1390
|
+
publishBackgroundTasks();
|
|
1391
|
+
}
|
|
1392
|
+
} else if (type === 'subagent.completed' || type === 'subagent.failed') {
|
|
1393
|
+
const total = Number(data.totalTokens);
|
|
1394
|
+
if (Number.isFinite(total) && total > 0) detail.totalTokens = total;
|
|
1395
|
+
scheduleTaskRefresh();
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function scheduleTaskRefresh() {
|
|
1401
|
+
if (!sessionRpc.tasks || taskRefreshTimer) return;
|
|
1402
|
+
taskRefreshTimer = setTimeout(() => {
|
|
1403
|
+
taskRefreshTimer = null;
|
|
1404
|
+
taskRefreshChain = taskRefreshChain.then(() => refreshBackgroundTasks()).catch(() => {});
|
|
1405
|
+
}, taskRefreshMs);
|
|
1406
|
+
taskRefreshTimer.unref?.();
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Re-read the runtime's task registry. Running agents and shells become
|
|
1411
|
+
* cards (with a progress line when the runtime has one); finished entries
|
|
1412
|
+
* are removed from the runtime's tracking so the registry cannot grow for
|
|
1413
|
+
* the life of the session. A refresh that fails leaves the last read in
|
|
1414
|
+
* place — a stale card beats a vanished one.
|
|
1415
|
+
*/
|
|
1416
|
+
async function refreshBackgroundTasks() {
|
|
1417
|
+
const target = session;
|
|
1418
|
+
if (!target || !sessionRpc.tasks) return;
|
|
1419
|
+
lastTaskRefreshAt = Date.now();
|
|
1420
|
+
let listed;
|
|
1421
|
+
try {
|
|
1422
|
+
listed = await target.rpc.tasks.list();
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
dbg('tasks.list failed', error?.message || String(error));
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
if (session !== target) return;
|
|
1428
|
+
const next = new Map();
|
|
1429
|
+
for (const task of Array.isArray(listed?.tasks) ? listed.tasks : []) {
|
|
1430
|
+
const id = String(task?.id || '').trim();
|
|
1431
|
+
if (!id || abandonedTasks.has(id)) continue;
|
|
1432
|
+
const status = String(task?.status || '').trim().toLowerCase();
|
|
1433
|
+
// Only running tasks are live. 'idle' (a multi-turn agent waiting for
|
|
1434
|
+
// messages) holds nothing and shows nowhere until it is messaged
|
|
1435
|
+
// (roadmap: tasks.sendMessage); finished entries are left to the
|
|
1436
|
+
// runtime's own tracking — removing them here could race the root
|
|
1437
|
+
// agent's `read_agent` follow-up that the completion notification
|
|
1438
|
+
// triggers.
|
|
1439
|
+
if (status === 'running') {
|
|
1440
|
+
next.set(id, { ...task, id, status, progressSummary: backgroundTasks.get(id)?.progressSummary || '' });
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
// Progress lines for the live set: the agent's latest intent / last
|
|
1444
|
+
// activity, a shell's last output line. One RPC per live task per refresh
|
|
1445
|
+
// (refreshes are debounced), best effort.
|
|
1446
|
+
if (typeof target.rpc.tasks.getProgress === 'function') {
|
|
1447
|
+
for (const task of next.values()) {
|
|
1448
|
+
try {
|
|
1449
|
+
const progress = (await target.rpc.tasks.getProgress({ id: task.id }))?.progress || null;
|
|
1450
|
+
task.progressSummary = summarizeTaskProgress(progress);
|
|
1451
|
+
} catch { /* a card without a progress line */ }
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
if (session !== target) return;
|
|
1455
|
+
const setChanged = next.size !== backgroundTasks.size
|
|
1456
|
+
|| [...next.keys()].some((id) => !backgroundTasks.has(id));
|
|
1457
|
+
backgroundTasks = next;
|
|
1458
|
+
publishBackgroundTasks({ immediate: setChanged });
|
|
1459
|
+
// The lifecycle poll (every `lifecyclePollMs`) picks the new set up; a
|
|
1460
|
+
// re-evaluation here would re-schedule a refresh and spin.
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function summarizeTaskProgress(progress) {
|
|
1464
|
+
if (!progress || typeof progress !== 'object') return '';
|
|
1465
|
+
if (progress.type === 'agent') {
|
|
1466
|
+
const intent = String(progress.latestIntent || '').trim();
|
|
1467
|
+
if (intent) return intent.slice(0, 300);
|
|
1468
|
+
const lines = Array.isArray(progress.recentActivity) ? progress.recentActivity : [];
|
|
1469
|
+
const last = lines.length ? String(lines[lines.length - 1]?.message || '').trim() : '';
|
|
1470
|
+
return last.slice(0, 300);
|
|
1471
|
+
}
|
|
1472
|
+
if (progress.type === 'shell') {
|
|
1473
|
+
const output = String(progress.recentOutput || '').trim();
|
|
1474
|
+
if (!output || output === '(no output yet)') return '';
|
|
1475
|
+
const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
1476
|
+
return (lines[lines.length - 1] || '').slice(0, 300);
|
|
1477
|
+
}
|
|
1478
|
+
return '';
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/** The live tasks from the registry (running agents + shells). */
|
|
1482
|
+
function runningBackgroundTasks() {
|
|
1483
|
+
return [...backgroundTasks.values()].filter((task) => task.status === 'running');
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* Stop one background task (the panel's per-card Stop, or a subagent lane's
|
|
1488
|
+
* Stop for a background agent). Returns whether the runtime cancelled it.
|
|
1489
|
+
*/
|
|
1490
|
+
async function stopBackgroundTask(taskId) {
|
|
1491
|
+
const id = String(taskId || '').trim();
|
|
1492
|
+
if (!id || !session || !sessionRpc.tasks) return false;
|
|
1493
|
+
try {
|
|
1494
|
+
const outcome = await session.rpc.tasks.cancel({ id });
|
|
1495
|
+
const cancelled = outcome?.cancelled === true;
|
|
1496
|
+
dbg(cancelled ? 'background task cancelled' : 'background task cancel refused', id);
|
|
1497
|
+
if (cancelled) scheduleTaskRefresh();
|
|
1498
|
+
return cancelled;
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
dbg('tasks.cancel failed', id, error?.message || String(error));
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* The compaction hold. A message pushed while the runtime is compacting
|
|
1509
|
+
* would be injected into the compaction's replay, so steering is held from
|
|
1510
|
+
* `session.compaction_start` to `_complete` (the runtime queues messages
|
|
1511
|
+
* sent during a compaction itself, but holding them relay-side keeps the
|
|
1512
|
+
* bubble cancellable and the composer truthful — the Claude rule).
|
|
1513
|
+
*/
|
|
1514
|
+
function observeCompaction(event) {
|
|
1515
|
+
const type = String(event?.type || '');
|
|
1516
|
+
if (event?.agentId) return;
|
|
1517
|
+
if (type === 'session.compaction_start') {
|
|
1518
|
+
compactingSince = Date.now();
|
|
1519
|
+
syncDeliveryReadiness();
|
|
1520
|
+
} else if (type === 'session.compaction_complete') {
|
|
1521
|
+
compactingSince = 0;
|
|
1522
|
+
syncDeliveryReadiness();
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function isCompacting() {
|
|
1527
|
+
if (!compactingSince) return false;
|
|
1528
|
+
if (Date.now() - compactingSince < compactionStaleMs) return true;
|
|
1529
|
+
dbg('compaction hold went stale; releasing it');
|
|
1530
|
+
compactingSince = 0;
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Keep `queuedLane` current. `pending_messages.modified` carries no payload
|
|
1536
|
+
* (it only says "the queue changed"), so it schedules a debounced
|
|
1537
|
+
* `rpc.queue.pendingItems()` read. What matters here is which pushed rows
|
|
1538
|
+
* are still in the QUEUED lane: those the runtime has not consumed and the
|
|
1539
|
+
* client may still cancel (`cancellableIds` on the steering snapshot).
|
|
1540
|
+
*/
|
|
1541
|
+
function observeQueueLane(event) {
|
|
1542
|
+
if (String(event?.type || '') !== 'pending_messages.modified') return;
|
|
1543
|
+
scheduleQueueLaneRefresh();
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** Un-steer's synchronous view of the runtime taking a prompt (see `recallWatches`). */
|
|
1547
|
+
function observeRecalls(event) {
|
|
1548
|
+
if (String(event?.type || '') !== 'user.message' || event?.agentId) return;
|
|
1549
|
+
const runtimeId = String(event?.data?.messageId || '').trim();
|
|
1550
|
+
if (!runtimeId) return;
|
|
1551
|
+
const watch = recallWatches.get(runtimeId);
|
|
1552
|
+
if (watch) watch.taken = true;
|
|
1553
|
+
const recalledRow = recalledRuntimeIds.get(runtimeId);
|
|
1554
|
+
if (recalledRow) {
|
|
1555
|
+
dbg('UN-STEER MISMATCH: a prompt recalled and cancelled is running after all; the remove took another message',
|
|
1556
|
+
recalledRow, runtimeId);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
function scheduleQueueLaneRefresh() {
|
|
1561
|
+
if (!sessionRpc.queue || queueLaneTimer) return;
|
|
1562
|
+
queueLaneTimer = setTimeout(() => {
|
|
1563
|
+
queueLaneTimer = null;
|
|
1564
|
+
queueLaneRefreshChain = queueLaneRefreshChain.then(() => refreshQueueLane()).catch(() => {});
|
|
1565
|
+
}, queueLaneRefreshMs);
|
|
1566
|
+
queueLaneTimer.unref?.();
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async function refreshQueueLane() {
|
|
1570
|
+
const target = session;
|
|
1571
|
+
if (!target || !sessionRpc.queue) return;
|
|
1572
|
+
try {
|
|
1573
|
+
const snapshot = await target.rpc.queue.pendingItems();
|
|
1574
|
+
const next = new Map();
|
|
1575
|
+
for (const item of Array.isArray(snapshot?.items) ? snapshot.items : []) {
|
|
1576
|
+
const runtimeId = String(item?.messageId || '').trim();
|
|
1577
|
+
const itemId = String(item?.id || '').trim();
|
|
1578
|
+
if (runtimeId && itemId) next.set(runtimeId, itemId);
|
|
1579
|
+
}
|
|
1580
|
+
if (session !== target) return;
|
|
1581
|
+
queuedLane = next;
|
|
1582
|
+
queuedItemCount = Array.isArray(snapshot?.items) ? snapshot.items.length : 0;
|
|
1583
|
+
const steering = Array.isArray(snapshot?.steeringMessages) ? snapshot.steeringMessages.length : 0;
|
|
1584
|
+
const inFlight = Number(snapshot?.inFlightSteeringCount) || 0;
|
|
1585
|
+
waitingSteers = Math.max(0, steering - inFlight);
|
|
1586
|
+
} catch (error) {
|
|
1587
|
+
dbg('queue.pendingItems failed', error?.message || String(error));
|
|
1588
|
+
}
|
|
1589
|
+
noteCancellableChange();
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** Tell the worker when the recallable set changed (see onCancellableChange). */
|
|
1593
|
+
function noteCancellableChange() {
|
|
1594
|
+
const signature = cancellableIds().join(',');
|
|
1595
|
+
if (signature === lastCancellableSignature) return;
|
|
1596
|
+
lastCancellableSignature = signature;
|
|
1597
|
+
try {
|
|
1598
|
+
onCancellableChange();
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
dbg('cancellable listener failed', error?.message || String(error));
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/** Our pushed steers the runtime has not started on yet, in send order. */
|
|
1605
|
+
function unconsumedSteers() {
|
|
1606
|
+
const list = [];
|
|
1607
|
+
for (const entry of ownedEntries()) {
|
|
1608
|
+
if (entry.role === 'steer' && !entry.consumed && !entry.settled && entry.runtimeId) list.push(entry);
|
|
1609
|
+
}
|
|
1610
|
+
return list.sort((a, b) => (a.sendSeq || 0) - (b.sendSeq || 0));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* How `entry` can still be pulled back out of the runtime, or null: by its
|
|
1615
|
+
* stable id when it sits in the queued lane, or with removeMostRecent when
|
|
1616
|
+
* it is the runtime's newest waiting message (our newest unconsumed steer,
|
|
1617
|
+
* at least one steer waiting, nothing in the queued lane that a LIFO remove
|
|
1618
|
+
* would take instead).
|
|
1619
|
+
*/
|
|
1620
|
+
function recallMethodFor(entry) {
|
|
1621
|
+
if (!entry || entry.role !== 'steer' || entry.consumed || entry.settled || !entry.runtimeId) return null;
|
|
1622
|
+
const itemId = queuedLane.get(entry.runtimeId);
|
|
1623
|
+
if (itemId) return { kind: 'queued', itemId };
|
|
1624
|
+
if (typeof session?.rpc?.queue?.removeMostRecent !== 'function') return null;
|
|
1625
|
+
if (steerSendsInFlight > 0 || queuedItemCount > 0 || waitingSteers < 1) return null;
|
|
1626
|
+
const pending = unconsumedSteers();
|
|
1627
|
+
return pending.length && pending[pending.length - 1] === entry ? { kind: 'newest' } : null;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** Probe the experimental RPC surfaces once per session. */
|
|
1631
|
+
function probeSessionRpc(target) {
|
|
1632
|
+
sessionRpc = {
|
|
1633
|
+
queue: typeof target?.rpc?.queue?.pendingItems === 'function'
|
|
1634
|
+
&& typeof target?.rpc?.queue?.removeAt === 'function',
|
|
1635
|
+
interruptMainTurn: typeof target?.rpc?.interruptMainTurn === 'function',
|
|
1636
|
+
tasks: typeof target?.rpc?.tasks?.list === 'function'
|
|
1637
|
+
&& typeof target?.rpc?.tasks?.cancel === 'function',
|
|
1638
|
+
};
|
|
1639
|
+
dbg(`copilot session rpc surfaces: queue=${sessionRpc.queue} interruptMainTurn=${sessionRpc.interruptMainTurn} tasks=${sessionRpc.tasks}`);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
590
1642
|
function observeBackgroundShells(event) {
|
|
591
1643
|
let changed;
|
|
592
1644
|
try {
|
|
@@ -595,6 +1647,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
595
1647
|
dbg('background shell tracking failed', String(event?.type || ''), error?.message || String(error));
|
|
596
1648
|
return;
|
|
597
1649
|
}
|
|
1650
|
+
if (changed.opened.length || changed.settled.length) publishBackgroundShellTasks();
|
|
598
1651
|
for (const shell of changed.opened) {
|
|
599
1652
|
dbg('detached shell started', shell.shellId, shell.description || '(no description)');
|
|
600
1653
|
}
|
|
@@ -602,9 +1655,12 @@ export function createCopilotSdkSessionRunner({
|
|
|
602
1655
|
dbg('detached shell settled', shell.shellId);
|
|
603
1656
|
const note = describeSettledShell(shell);
|
|
604
1657
|
// Only carried when there is no turn to publish into: inside a turn the
|
|
605
|
-
// normalizer already narrates the `read_bash` that reads the output.
|
|
1658
|
+
// normalizer already narrates the `read_bash` that reads the output. A
|
|
1659
|
+
// settled turn mid-publish does NOT count as a turn here — its row is
|
|
1660
|
+
// closed to new lines, so the note belongs to the continuation this
|
|
1661
|
+
// notification is about to trigger.
|
|
606
1662
|
if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
|
|
607
|
-
pendingActivities.push(note);
|
|
1663
|
+
pendingActivities.push({ text: note, reportsFailure: false });
|
|
608
1664
|
}
|
|
609
1665
|
}
|
|
610
1666
|
// Only the runtime's own `system.notification` heralds a continuation. A
|
|
@@ -617,9 +1673,39 @@ export function createCopilotSdkSessionRunner({
|
|
|
617
1673
|
if (changed.heralded) continuationDueSince = Date.now();
|
|
618
1674
|
}
|
|
619
1675
|
|
|
1676
|
+
/**
|
|
1677
|
+
* A live event was captured to a turn that settled before its chain link
|
|
1678
|
+
* ran. It is NOT history — it belongs to whatever the runtime is doing now:
|
|
1679
|
+
* an already-open successor turn, or a continuation this very event should
|
|
1680
|
+
* open. Runs on the dispatch chain, so re-routed events keep arrival order
|
|
1681
|
+
* relative to everything captured after them.
|
|
1682
|
+
*/
|
|
1683
|
+
async function redispatchSettledEvent(event) {
|
|
1684
|
+
let turn = activeTurn;
|
|
1685
|
+
// A settled `activeTurn` cannot happen — `settle`/`fail` release the slot
|
|
1686
|
+
// — but it is guarded anyway: re-routing INTO a settled turn would recurse
|
|
1687
|
+
// through this function forever, so it is treated as "no owner".
|
|
1688
|
+
if (!turn || turn.settled) {
|
|
1689
|
+
// Same rules as `routeEvent`: a pushed prompt's late run is its own
|
|
1690
|
+
// turn; otherwise only the start of work opens a row.
|
|
1691
|
+
turn = adoptOrphanedRun(event);
|
|
1692
|
+
if (!turn) {
|
|
1693
|
+
if (!isContinuationOpeningEvent(event)) return;
|
|
1694
|
+
turn = openContinuationTurn();
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
await handleTurnEvent(turn, event);
|
|
1698
|
+
}
|
|
1699
|
+
|
|
620
1700
|
async function handleTurnEvent(turn, event) {
|
|
621
|
-
if (turn.settled)
|
|
1701
|
+
if (turn.settled) {
|
|
1702
|
+
await redispatchSettledEvent(event);
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
622
1705
|
turn.armStall?.();
|
|
1706
|
+
// Root work ends on the runtime's own terminator, so a question-only
|
|
1707
|
+
// continuation it joins no longer closes itself (whileAwaitingHuman).
|
|
1708
|
+
if (isContinuationOpeningEvent(event)) turn.rootWork = true;
|
|
623
1709
|
let actions = [];
|
|
624
1710
|
try {
|
|
625
1711
|
actions = turn.normalizer.normalize(event);
|
|
@@ -627,6 +1713,12 @@ export function createCopilotSdkSessionRunner({
|
|
|
627
1713
|
dbg('normalize failed', String(event?.type || ''), error?.message || String(error));
|
|
628
1714
|
return;
|
|
629
1715
|
}
|
|
1716
|
+
// The prompt boundary: the runtime has started work on a prompt. Decides
|
|
1717
|
+
// which row owns the segment the normalizer just opened, and settles the
|
|
1718
|
+
// previous run's row when this one is a new run (see noteUserMessage).
|
|
1719
|
+
if (String(event?.type || '') === 'user.message' && !event?.agentId) {
|
|
1720
|
+
await noteUserMessage(turn, event);
|
|
1721
|
+
}
|
|
630
1722
|
for (const action of actions) {
|
|
631
1723
|
if (action.channel === 'result') {
|
|
632
1724
|
turn.result = action.payload;
|
|
@@ -646,25 +1738,110 @@ export function createCopilotSdkSessionRunner({
|
|
|
646
1738
|
while (turn.bufferedActions.length) {
|
|
647
1739
|
const batch = turn.bufferedActions.splice(0);
|
|
648
1740
|
for (const action of batch) {
|
|
649
|
-
await
|
|
1741
|
+
await dispatchOwned(turn, action).catch(() => {});
|
|
650
1742
|
}
|
|
651
1743
|
}
|
|
652
1744
|
}
|
|
653
1745
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1746
|
+
/**
|
|
1747
|
+
* The relay's `ask_user` tool: same name and fields as the runtime's
|
|
1748
|
+
* built-in, plus `multi_select`. The handler runs under exactly the same
|
|
1749
|
+
* human-gating as the built-in path (`onUserInputRequest`): the stall
|
|
1750
|
+
* watchdog is held, steering is held (a question card is open), and a card
|
|
1751
|
+
* raised the moment a continuation opens waits for its row. It never
|
|
1752
|
+
* throws: a failed card answers in-band so the model can carry on.
|
|
1753
|
+
*/
|
|
1754
|
+
function buildRelayAskUserTool() {
|
|
1755
|
+
return {
|
|
1756
|
+
name: RELAY_ASK_USER_TOOL_NAME,
|
|
1757
|
+
description: RELAY_ASK_USER_TOOL_DESCRIPTION,
|
|
1758
|
+
parameters: RELAY_ASK_USER_TOOL_PARAMETERS,
|
|
1759
|
+
overridesBuiltInTool: true,
|
|
1760
|
+
// The built-in asks no permission either: asking a question is not an action.
|
|
1761
|
+
skipPermission: true,
|
|
1762
|
+
defer: 'never',
|
|
1763
|
+
handler: async (args, invocation) => {
|
|
1764
|
+
const { question, choices, multiSelect } = parseAskUserToolArguments(args);
|
|
1765
|
+
try {
|
|
1766
|
+
const result = await whileAwaitingHuman(async (turn) => {
|
|
1767
|
+
await awaitInteractiveRow(turn);
|
|
1768
|
+
// Cancelled by either side: the turn (Stop, teardown) or the
|
|
1769
|
+
// runtime itself (a subagent cancelled via tasks.cancel, a
|
|
1770
|
+
// disconnect) — a card nobody will read must not hold steering.
|
|
1771
|
+
const signal = combineSignals(turn?.abortController?.signal, invocation?.signal);
|
|
1772
|
+
return questionBridge.askUserInput(
|
|
1773
|
+
{ question, choices, allowFreeform: true, multiSelect, source: 'relay-ask-user-tool' },
|
|
1774
|
+
{ signal, message: turn?.message },
|
|
1775
|
+
);
|
|
1776
|
+
});
|
|
1777
|
+
return formatAskUserToolResult({
|
|
1778
|
+
answer: result?.answer,
|
|
1779
|
+
wasFreeform: result?.wasFreeform !== false,
|
|
1780
|
+
timedOut: result?.timedOut === true,
|
|
1781
|
+
});
|
|
1782
|
+
} catch (error) {
|
|
1783
|
+
dbg('relay ask_user failed', error?.message || String(error));
|
|
1784
|
+
return USER_INPUT_UNSUPPORTED_ANSWER;
|
|
1785
|
+
}
|
|
665
1786
|
},
|
|
666
|
-
|
|
667
|
-
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** One signal that aborts when any of the given ones does (nulls ignored). */
|
|
1791
|
+
function combineSignals(...signals) {
|
|
1792
|
+
const live = signals.filter((signal) => signal && typeof signal.aborted === 'boolean');
|
|
1793
|
+
if (live.length <= 1) return live[0] || null;
|
|
1794
|
+
if (typeof AbortSignal.any === 'function') return AbortSignal.any(live);
|
|
1795
|
+
const controller = new AbortController();
|
|
1796
|
+
for (const signal of live) {
|
|
1797
|
+
if (signal.aborted) { controller.abort(); break; }
|
|
1798
|
+
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
1799
|
+
}
|
|
1800
|
+
return controller.signal;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Open a session, retrying once without the relay's `ask_user` when the
|
|
1805
|
+
* runtime refuses the override (an older runtime). The refusal is
|
|
1806
|
+
* remembered until the runtime is stopped — the next runtime (possibly
|
|
1807
|
+
* auto-updated under the relay) gets another try.
|
|
1808
|
+
*/
|
|
1809
|
+
async function openSession(open, config) {
|
|
1810
|
+
try {
|
|
1811
|
+
return await open(config);
|
|
1812
|
+
} catch (error) {
|
|
1813
|
+
if (!config?.tools?.length || !isToolOverrideRejection(error) || isSessionNotFoundError(error)) throw error;
|
|
1814
|
+
dbg('the runtime refused the relay ask_user override; using its built-in ask_user', error?.message || String(error));
|
|
1815
|
+
askUserOverrideRejected = true;
|
|
1816
|
+
const { tools: _tools, ...withoutTools } = config;
|
|
1817
|
+
return open(withoutTools);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function buildSessionConfig(model, reasoningEffort = null) {
|
|
1822
|
+
// Built once per session, not per request. Reads the mode off the LIVE turn
|
|
1823
|
+
// rather than closing over the one the session was built with, because one
|
|
1824
|
+
// session serves every turn and the user can switch modes between them.
|
|
1825
|
+
// With no turn live (a background agent's request) it is the mode of the
|
|
1826
|
+
// last delivered turn, as for a continuation.
|
|
1827
|
+
const decidePermission = createCopilotPermissionHandler({
|
|
1828
|
+
// Only the ask-mode branch actually blocks on a human; wrapping it keeps
|
|
1829
|
+
// the stall watchdog off a turn where someone is deciding, and only
|
|
1830
|
+
// there can an approval open a row of its own.
|
|
1831
|
+
bridge: {
|
|
1832
|
+
askToolApproval: (permissionRequest, options) => whileAwaitingHuman(async (turn) => {
|
|
1833
|
+
// An ask-mode approval raised the moment a continuation opens must
|
|
1834
|
+
// wait for the row its card will hang off — see `awaitInteractiveRow`.
|
|
1835
|
+
await awaitInteractiveRow(turn);
|
|
1836
|
+
return questionBridge.askToolApproval(permissionRequest, {
|
|
1837
|
+
...options,
|
|
1838
|
+
signal: turn?.abortController?.signal || options?.signal || null,
|
|
1839
|
+
message: turn?.message,
|
|
1840
|
+
});
|
|
1841
|
+
}),
|
|
1842
|
+
},
|
|
1843
|
+
getRelayMode: () => activeTurn?.message?.relayMode || lastRelayMode,
|
|
1844
|
+
// An aborted turn must not leave the human staring at a card whose answer
|
|
668
1845
|
// nothing will read; the bridge times the card out instead.
|
|
669
1846
|
getSignal: () => activeTurn?.abortController?.signal || null,
|
|
670
1847
|
dbg,
|
|
@@ -676,9 +1853,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
676
1853
|
// hosted (`github`) session, which is the common case.
|
|
677
1854
|
//
|
|
678
1855
|
// The resolved block is remembered: its token ceilings describe THIS model,
|
|
679
|
-
// and
|
|
1856
|
+
// and no model-switch RPC can update them (see `applySelection`).
|
|
680
1857
|
const provider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
|
|
681
1858
|
byokProvider = provider;
|
|
1859
|
+
const effort = normalizeRelayEffort(reasoningEffort);
|
|
682
1860
|
return {
|
|
683
1861
|
// The relay session id IS the SDK session id, so the runtime's own state
|
|
684
1862
|
// under ~/.copilot/session-state/<id> is addressable by conversation and
|
|
@@ -686,6 +1864,13 @@ export function createCopilotSdkSessionRunner({
|
|
|
686
1864
|
sessionId: sdkSessionId,
|
|
687
1865
|
...(model ? { model } : {}),
|
|
688
1866
|
...(provider ? { provider } : {}),
|
|
1867
|
+
// BYOK only: the rebuilt config is the switch mechanism for these
|
|
1868
|
+
// sessions, so the effort must ride in it or a dispose+resume model
|
|
1869
|
+
// switch silently drops it. Hosted sessions apply effort through the
|
|
1870
|
+
// validated RPC path instead (`SessionConfigBase.reasoningEffort` is
|
|
1871
|
+
// only valid for models that support it, which cannot be checked before
|
|
1872
|
+
// the session exists).
|
|
1873
|
+
...(provider && effort ? { reasoningEffort: effort } : {}),
|
|
689
1874
|
// Not a default: without this the SDK emits no deltas at all and the
|
|
690
1875
|
// transcript only updates when the whole message lands.
|
|
691
1876
|
streaming: true,
|
|
@@ -694,6 +1879,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
694
1879
|
// The runtime's own defaults, pinned. See DEFAULT_INFINITE_SESSION_CONFIG.
|
|
695
1880
|
...(infiniteSessionConfig ? { infiniteSessions: { ...infiniteSessionConfig } } : {}),
|
|
696
1881
|
onEvent: routeEvent,
|
|
1882
|
+
// The relay's `ask_user` (see copilot-ask-user-tool.mjs): the built-in's
|
|
1883
|
+
// fields plus `multi_select`, so a model can declare a multi-select
|
|
1884
|
+
// question instead of the relay guessing from its wording.
|
|
1885
|
+
...(relayAskUserTool && !askUserOverrideRejected ? { tools: [buildRelayAskUserTool()] } : {}),
|
|
697
1886
|
// Permission policy follows the conversation's relay mode: agent and
|
|
698
1887
|
// autopilot auto-approve, plan denies non-read tools with feedback, and
|
|
699
1888
|
// ask asks the human through a relay question card. The handler reads the
|
|
@@ -717,9 +1906,15 @@ export function createCopilotSdkSessionRunner({
|
|
|
717
1906
|
// the deserializer is strict, so it is always a real boolean.
|
|
718
1907
|
onUserInputRequest: async (request) => {
|
|
719
1908
|
try {
|
|
720
|
-
const { answer, wasFreeform } = await whileAwaitingHuman(() =>
|
|
721
|
-
|
|
722
|
-
|
|
1909
|
+
const { answer, wasFreeform } = await whileAwaitingHuman(async (turn) => {
|
|
1910
|
+
// Same gate as the approval path: an `ask_user` fired straight
|
|
1911
|
+
// after a continuation opened must get the row, not a null id.
|
|
1912
|
+
await awaitInteractiveRow(turn);
|
|
1913
|
+
return questionBridge.askUserInput(request, {
|
|
1914
|
+
signal: turn?.abortController?.signal || null,
|
|
1915
|
+
message: turn?.message,
|
|
1916
|
+
});
|
|
1917
|
+
});
|
|
723
1918
|
return { answer, wasFreeform };
|
|
724
1919
|
} catch (error) {
|
|
725
1920
|
dbg('user input question failed', error?.message || String(error));
|
|
@@ -736,12 +1931,59 @@ export function createCopilotSdkSessionRunner({
|
|
|
736
1931
|
feedback: posted ? EXIT_PLAN_BOARD_POSTED_FEEDBACK : EXIT_PLAN_NO_BOARD_FEEDBACK,
|
|
737
1932
|
};
|
|
738
1933
|
},
|
|
739
|
-
// Structured elicitation
|
|
740
|
-
//
|
|
741
|
-
|
|
1934
|
+
// Structured elicitation (audit #17): a form-mode request with a schema
|
|
1935
|
+
// becomes a relay question card carrying `requestedSchema`; the relay's
|
|
1936
|
+
// answer route validates the submission and stores `structuredAnswer`,
|
|
1937
|
+
// which maps 1:1 onto the SDK's `ElicitationResult.content`. Everything
|
|
1938
|
+
// that cannot round-trip — url mode (a browser redirect), a missing or
|
|
1939
|
+
// non-object schema, a bridge without the structured surface, a timeout,
|
|
1940
|
+
// a closing bridge, an answer the relay could not validate — declines
|
|
1941
|
+
// in-band exactly as before, which lets the model continue where a hang
|
|
1942
|
+
// or a throw would fail the tool call silently.
|
|
1943
|
+
onElicitationRequest: (request) => handleElicitationRequest(request),
|
|
742
1944
|
};
|
|
743
1945
|
}
|
|
744
1946
|
|
|
1947
|
+
/**
|
|
1948
|
+
* `onElicitationRequest` → a schema-carrying relay question card →
|
|
1949
|
+
* `{ action: 'accept', content }` with the validated structured answer, or
|
|
1950
|
+
* `{ action: 'decline' }` for everything that cannot round-trip.
|
|
1951
|
+
*
|
|
1952
|
+
* Runs under the same human-gating as `ask_user`: the stall watchdog is held
|
|
1953
|
+
* off while the card waits, and an elicitation raised the moment a
|
|
1954
|
+
* continuation opens waits for the row its card will hang off (`rowReady`)
|
|
1955
|
+
* instead of minting a card against a null message id.
|
|
1956
|
+
*/
|
|
1957
|
+
async function handleElicitationRequest(request) {
|
|
1958
|
+
try {
|
|
1959
|
+
const mode = String(request?.mode || 'form').trim().toLowerCase();
|
|
1960
|
+
const schema = request?.requestedSchema;
|
|
1961
|
+
const hasSchema = !!schema && typeof schema === 'object' && !Array.isArray(schema)
|
|
1962
|
+
&& !!schema.properties && typeof schema.properties === 'object';
|
|
1963
|
+
if (mode === 'url' || !hasSchema || typeof questionBridge.askStructured !== 'function') {
|
|
1964
|
+
return { action: 'decline' };
|
|
1965
|
+
}
|
|
1966
|
+
const source = String(request?.elicitationSource || '').trim();
|
|
1967
|
+
const message = String(request?.message || '').trim()
|
|
1968
|
+
|| 'Copilot needs structured input to continue this turn.';
|
|
1969
|
+
const result = await whileAwaitingHuman(async (turn) => {
|
|
1970
|
+
await awaitInteractiveRow(turn);
|
|
1971
|
+
return questionBridge.askStructured({
|
|
1972
|
+
prompt: source ? `${message}\n\n(Requested by ${source}.)` : message,
|
|
1973
|
+
requestedSchema: schema,
|
|
1974
|
+
}, { signal: turn?.abortController?.signal || null, message: turn?.message });
|
|
1975
|
+
});
|
|
1976
|
+
const content = result?.structuredAnswer;
|
|
1977
|
+
if (result?.timedOut || !content || typeof content !== 'object' || Array.isArray(content)) {
|
|
1978
|
+
return { action: 'decline' };
|
|
1979
|
+
}
|
|
1980
|
+
return { action: 'accept', content };
|
|
1981
|
+
} catch (error) {
|
|
1982
|
+
dbg('elicitation bridging failed, declining', error?.message || String(error));
|
|
1983
|
+
return { action: 'decline' };
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
745
1987
|
/**
|
|
746
1988
|
* Post the `plan_ready` board for a turn. Returns whether a board went out,
|
|
747
1989
|
* which the exit-plan handler turns into the feedback the agent sees.
|
|
@@ -779,8 +2021,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
779
2021
|
dbg('copilot runtime exited', detail);
|
|
780
2022
|
const turn = activeTurn;
|
|
781
2023
|
if (turn && !turn.settled) {
|
|
782
|
-
//
|
|
783
|
-
//
|
|
2024
|
+
// driveTurn tears the runtime down and publishes the failure record
|
|
2025
|
+
// onto every row the turn owns.
|
|
784
2026
|
turn.fail(new Error(`the Copilot runtime exited before the turn completed (${detail})`));
|
|
785
2027
|
return;
|
|
786
2028
|
}
|
|
@@ -815,65 +2057,69 @@ export function createCopilotSdkSessionRunner({
|
|
|
815
2057
|
}
|
|
816
2058
|
|
|
817
2059
|
/**
|
|
818
|
-
*
|
|
819
|
-
* just runs on the session's current model (and says so in the log).
|
|
2060
|
+
* Bring the live session onto the dequeued row's (model, effort) selection.
|
|
820
2061
|
*
|
|
821
|
-
*
|
|
822
|
-
*
|
|
2062
|
+
* Hosted sessions go through `modelSwitch.apply` and its OBSERVED RPCs
|
|
2063
|
+
* (audit #9/#13): a selection the runtime does not confirm — a thrown
|
|
2064
|
+
* switchTo, a confirmation-required result, a deferred switch that never
|
|
2065
|
+
* drains, an unsupported effort level — THROWS the unconfirmed error, which
|
|
2066
|
+
* fails the row terminally before the prompt is ever sent. The common path
|
|
2067
|
+
* (same model, same effort) costs zero RPCs.
|
|
2068
|
+
*
|
|
2069
|
+
* BYOK sessions try the same RPCs first, but only when the freshly resolved
|
|
2070
|
+
* `SessionConfig.provider` block for the target model matches the one the
|
|
2071
|
+
* session was built with: the block carries MODEL-SPECIFIC token ceilings
|
|
2072
|
+
* that no RPC can update (runtime 1.0.82 has no setProvider, and the
|
|
2073
|
+
* registry surface is rejected alongside the singular whole-session
|
|
2074
|
+
* `provider`), so an in-place switch is only sufficient when the ceilings do
|
|
2075
|
+
* not move. A block that differs — or an RPC the runtime rejects — falls
|
|
2076
|
+
* back to the dispose+resume rebuild, which carries the effort in the
|
|
2077
|
+
* rebuilt config. Nothing is lost either way: the relay session id IS the
|
|
2078
|
+
* SDK session id, so the rebuild takes the ordinary resume path.
|
|
823
2079
|
*/
|
|
824
|
-
async function
|
|
825
|
-
if (!
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
2080
|
+
async function applySelection(model, effort) {
|
|
2081
|
+
if (!byokProvider) {
|
|
2082
|
+
await modelSwitch.apply(session, { model, effort, byok: false });
|
|
2083
|
+
return session;
|
|
2084
|
+
}
|
|
2085
|
+
const nextProvider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
|
|
2086
|
+
const ceilingsMatch = JSON.stringify(nextProvider) === JSON.stringify(byokProvider);
|
|
2087
|
+
if (ceilingsMatch) {
|
|
2088
|
+
const attempt = await modelSwitch.apply(session, { model, effort, byok: true });
|
|
2089
|
+
if (attempt.ok) return session;
|
|
2090
|
+
dbg('BYOK model RPC not honoured, rebuilding the session', model, attempt.detail || '');
|
|
2091
|
+
} else {
|
|
2092
|
+
dbg('BYOK ceilings differ for the target model; rebuilding the session', model);
|
|
831
2093
|
}
|
|
2094
|
+
return rebuildByokSession(model, effort);
|
|
832
2095
|
}
|
|
833
2096
|
|
|
834
2097
|
/**
|
|
835
|
-
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
*
|
|
842
|
-
* experimental named-`providers`/`models` surface, which the runtime
|
|
843
|
-
* explicitly REJECTS when combined with the singular whole-session
|
|
844
|
-
* `provider` this worker uses.
|
|
845
|
-
*
|
|
846
|
-
* So a bare `setModel()` on a BYOK session leaves the previous model's
|
|
847
|
-
* ceilings in place, and both directions of that are harmful: ceilings too
|
|
848
|
-
* high turn compaction into hard API rejections, ceilings too low compact a
|
|
849
|
-
* conversation that had plenty of room left.
|
|
850
|
-
*
|
|
851
|
-
* The session is therefore disposed and rebuilt with freshly resolved
|
|
852
|
-
* ceilings. Nothing is lost: the relay session id IS the SDK session id, the
|
|
853
|
-
* runtime's state persists under it, and the rebuild takes the ordinary
|
|
854
|
-
* resume path — the same one every worker restart already uses.
|
|
2098
|
+
* Dispose the BYOK session and rebuild it with freshly resolved ceilings —
|
|
2099
|
+
* the switch mechanism of last resort for a session whose
|
|
2100
|
+
* `SessionConfig.provider` is immutable. The rebuilt config carries the
|
|
2101
|
+
* requested model AND effort, and the resumed session is trusted to be on
|
|
2102
|
+
* them by construction (`ProviderConfig.modelId` falls back to
|
|
2103
|
+
* `SessionConfig.model`), so this path never re-enters the RPC attempt —
|
|
2104
|
+
* which is also what makes it terminate: RPC refusal → rebuild → done.
|
|
855
2105
|
*/
|
|
856
|
-
async function
|
|
857
|
-
|
|
858
|
-
await applyModel(model);
|
|
859
|
-
return session;
|
|
860
|
-
}
|
|
861
|
-
dbg('rebuilding the copilot session for a BYOK model switch', model);
|
|
2106
|
+
async function rebuildByokSession(model, effort) {
|
|
2107
|
+
dbg('rebuilding the copilot session for a BYOK model/effort switch', model);
|
|
862
2108
|
const closing = session;
|
|
863
2109
|
session = null;
|
|
864
|
-
|
|
2110
|
+
modelSwitch.reset();
|
|
865
2111
|
// Disconnect first so the runtime is not holding two handles on one
|
|
866
2112
|
// session id while the resume runs.
|
|
867
2113
|
try { await closing?.disconnect?.(); } catch (error) {
|
|
868
2114
|
dbg('session disconnect before model switch failed', error?.message || String(error));
|
|
869
2115
|
}
|
|
870
|
-
return ensureSession(model,
|
|
2116
|
+
return ensureSession(model, effort);
|
|
871
2117
|
}
|
|
872
2118
|
|
|
873
|
-
async function ensureSession(model,
|
|
2119
|
+
async function ensureSession(model, effort) {
|
|
874
2120
|
await ensureClient();
|
|
875
2121
|
if (!session) {
|
|
876
|
-
const config = buildSessionConfig(model,
|
|
2122
|
+
const config = buildSessionConfig(model, effort);
|
|
877
2123
|
// Resume first, always. On a brand-new conversation this costs one
|
|
878
2124
|
// failed RPC; on every other path (worker restart, idle shutdown,
|
|
879
2125
|
// relay restart) it is the difference between continuing the
|
|
@@ -882,7 +2128,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
882
2128
|
// exists and the NEXT reconnect must resume it.
|
|
883
2129
|
let resumed = false;
|
|
884
2130
|
try {
|
|
885
|
-
session = await client.resumeSession(sdkSessionId, config);
|
|
2131
|
+
session = await openSession((cfg) => client.resumeSession(sdkSessionId, cfg), config);
|
|
886
2132
|
resumed = true;
|
|
887
2133
|
dbg(`resumed copilot session ${sdkSessionId.slice(0, 8)}`);
|
|
888
2134
|
} catch (error) {
|
|
@@ -896,24 +2142,45 @@ export function createCopilotSdkSessionRunner({
|
|
|
896
2142
|
throw error;
|
|
897
2143
|
}
|
|
898
2144
|
dbg('copilot session state not found, creating', error?.message || String(error));
|
|
899
|
-
session = await client.createSession(config);
|
|
2145
|
+
session = await openSession((cfg) => client.createSession(cfg), config);
|
|
900
2146
|
dbg(`created copilot session ${sdkSessionId.slice(0, 8)}`);
|
|
901
2147
|
}
|
|
902
|
-
|
|
903
|
-
|
|
2148
|
+
// Which experimental surfaces THIS runtime has (it auto-updates under
|
|
2149
|
+
// the relay): decides un-steer and the targeted Stop for the session.
|
|
2150
|
+
probeSessionRpc(session);
|
|
2151
|
+
scheduleQueueLaneRefresh();
|
|
2152
|
+
if (resumed && !byokProvider) {
|
|
2153
|
+
// A resumed HOSTED session keeps whatever model it was created with —
|
|
904
2154
|
// `config.model` is not guaranteed to be honoured on resume — so the
|
|
905
|
-
//
|
|
906
|
-
// it is what made a mismatch permanent:
|
|
907
|
-
// equal the request, so the per-turn switch below
|
|
908
|
-
|
|
909
|
-
|
|
2155
|
+
// selection is applied explicitly (and observably) rather than
|
|
2156
|
+
// assumed. Assuming it is what made a mismatch permanent: the tracked
|
|
2157
|
+
// model would already equal the request, so the per-turn switch below
|
|
2158
|
+
// could never fire.
|
|
2159
|
+
modelSwitch.reset();
|
|
2160
|
+
await modelSwitch.apply(session, { model, effort, byok: false });
|
|
910
2161
|
} else {
|
|
911
|
-
|
|
2162
|
+
// A created session is on `config.model` by construction; a resumed
|
|
2163
|
+
// BYOK session is trusted the same way because `config.model` feeds
|
|
2164
|
+
// `ProviderConfig.modelId` — and an RPC attempt here would recurse
|
|
2165
|
+
// through the rebuild path that just built this config.
|
|
2166
|
+
modelSwitch.noteApplied(model || '', byokProvider ? effort : null);
|
|
2167
|
+
if (!byokProvider) {
|
|
2168
|
+
// A hosted CREATE carries no effort in the config (the field is only
|
|
2169
|
+
// valid for models that support it, unknowable pre-session), so an
|
|
2170
|
+
// explicit level on the conversation's first turn still goes through
|
|
2171
|
+
// the validated effort-only RPC. `null` effort is a no-op here.
|
|
2172
|
+
await modelSwitch.apply(session, { model, effort, byok: false });
|
|
2173
|
+
}
|
|
912
2174
|
}
|
|
2175
|
+
// The freshly (re)built session is the first chance to see the runtime's
|
|
2176
|
+
// catalog — publish it so a restarted relay repopulates without waiting
|
|
2177
|
+
// for a model switch. Off the turn's critical path (fire-and-forget) and
|
|
2178
|
+
// deduped, so an unchanged catalog on every resume costs one list() and
|
|
2179
|
+
// no POST.
|
|
2180
|
+
publishModelSnapshot(resumed ? 'session-resume' : 'session-start');
|
|
913
2181
|
return session;
|
|
914
2182
|
}
|
|
915
|
-
|
|
916
|
-
return session;
|
|
2183
|
+
return applySelection(model, effort);
|
|
917
2184
|
}
|
|
918
2185
|
|
|
919
2186
|
async function stopRuntime(reason) {
|
|
@@ -924,15 +2191,38 @@ export function createCopilotSdkSessionRunner({
|
|
|
924
2191
|
const closingClient = client;
|
|
925
2192
|
session = null;
|
|
926
2193
|
client = null;
|
|
927
|
-
|
|
2194
|
+
modelSwitch.reset();
|
|
928
2195
|
// Detached shells are children of the runtime process, so stopping it ends
|
|
929
2196
|
// them: the tracked set is state about a process that no longer exists and
|
|
930
2197
|
// must not pin the next one. The replay gate is reset for the same reason —
|
|
931
2198
|
// the next connection resumes and arms its own window.
|
|
932
|
-
backgroundShells.
|
|
2199
|
+
if (backgroundShells.size() || backgroundTasks.size) {
|
|
2200
|
+
backgroundShells.reset();
|
|
2201
|
+
// Background agents and shells die with the runtime (live-probed: a
|
|
2202
|
+
// disconnect cancels the agents and drops the shells from the registry);
|
|
2203
|
+
// clear their panel cards too.
|
|
2204
|
+
backgroundTasks = new Map();
|
|
2205
|
+
agentDetails.clear();
|
|
2206
|
+
publishBackgroundTasks({ immediate: true });
|
|
2207
|
+
}
|
|
2208
|
+
if (taskRefreshTimer) {
|
|
2209
|
+
clearTimeout(taskRefreshTimer);
|
|
2210
|
+
taskRefreshTimer = null;
|
|
2211
|
+
}
|
|
933
2212
|
replayGate.reset();
|
|
2213
|
+
// A settled turn waiting on a card (driveTurn) would wait for an answer
|
|
2214
|
+
// nothing can take any more: the agent that asked died with the runtime.
|
|
2215
|
+
for (const turn of publishingTurns) turn.abortController.abort();
|
|
934
2216
|
continuationDueSince = 0;
|
|
935
2217
|
pendingActivities = [];
|
|
2218
|
+
compactingSince = 0;
|
|
2219
|
+
queuedLane = new Map();
|
|
2220
|
+
waitingSteers = 0;
|
|
2221
|
+
queuedItemCount = 0;
|
|
2222
|
+
steerSendsInFlight = 0;
|
|
2223
|
+
sessionRpc = { queue: false, interruptMainTurn: false, tasks: false };
|
|
2224
|
+
askUserOverrideRejected = false;
|
|
2225
|
+
syncDeliveryReadiness();
|
|
936
2226
|
if (!closingSession && !closingClient) return;
|
|
937
2227
|
dbg(`stopping the copilot runtime (${reason})`);
|
|
938
2228
|
try { await closingSession?.disconnect?.(); } catch (error) {
|
|
@@ -975,22 +2265,62 @@ export function createCopilotSdkSessionRunner({
|
|
|
975
2265
|
dbg('continuation grace expired with no continuation turn');
|
|
976
2266
|
continuationDueSince = 0;
|
|
977
2267
|
}
|
|
2268
|
+
if (sessionRpc.tasks) {
|
|
2269
|
+
// The registry is the truth: live agents and shells hold the runtime up
|
|
2270
|
+
// to the cap (the relay's background-task slider, 0 = unlimited), and on
|
|
2271
|
+
// expiry they are CANCELLED — the runtime can stop them now — rather
|
|
2272
|
+
// than forgotten. Bounded: a task whose cancel the runtime refuses a
|
|
2273
|
+
// few times is abandoned instead of pinning the runtime forever. A
|
|
2274
|
+
// re-read once per lifecycle poll catches state the events miss (a
|
|
2275
|
+
// shell that exited after a cancel emits no notification).
|
|
2276
|
+
const capMs = Number(getBackgroundTaskTimeoutMs()) || 0;
|
|
2277
|
+
const live = runningBackgroundTasks();
|
|
2278
|
+
if (!live.length) return false;
|
|
2279
|
+
if (capMs > 0) {
|
|
2280
|
+
for (const task of live) {
|
|
2281
|
+
const startedAt = Date.parse(String(task.startedAt || '')) || agentDetails.get(task.id)?.startedAt || 0;
|
|
2282
|
+
if (!startedAt || now - startedAt < capMs) continue;
|
|
2283
|
+
const refusals = taskCancelRefusals.get(task.id) || 0;
|
|
2284
|
+
if (refusals >= MAX_TASK_CANCEL_REFUSALS) {
|
|
2285
|
+
dbg('background task cap reached and the runtime would not cancel it; abandoning it', task.id);
|
|
2286
|
+
abandonedTasks.add(task.id);
|
|
2287
|
+
backgroundTasks.delete(task.id);
|
|
2288
|
+
publishBackgroundTasks({ immediate: true });
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
dbg('background task cap reached; cancelling it', task.id, task.description || task.command || '');
|
|
2292
|
+
void stopBackgroundTask(task.id).then((cancelled) => {
|
|
2293
|
+
if (!cancelled) taskCancelRefusals.set(task.id, (taskCancelRefusals.get(task.id) || 0) + 1);
|
|
2294
|
+
});
|
|
2295
|
+
}
|
|
2296
|
+
if (!runningBackgroundTasks().length) return false;
|
|
2297
|
+
}
|
|
2298
|
+
if (now - lastTaskRefreshAt >= lifecyclePollMs) scheduleTaskRefresh();
|
|
2299
|
+
return true;
|
|
2300
|
+
}
|
|
978
2301
|
if (!backgroundShells.size()) return false;
|
|
979
|
-
|
|
2302
|
+
// Without the registry there is no Stop and no card worth the name, so an
|
|
2303
|
+
// unlimited slider (0) must not mean "a forgotten `sleep 99999` pins the
|
|
2304
|
+
// runtime forever": the old 30-minute default applies on this path.
|
|
2305
|
+
const capMs = Number(getBackgroundTaskTimeoutMs()) || DEFAULT_BACKGROUND_TASK_TIMEOUT_MS;
|
|
2306
|
+
let expiredAny = false;
|
|
980
2307
|
for (const shell of backgroundShells.expireOlderThan(capMs)) {
|
|
2308
|
+
expiredAny = true;
|
|
981
2309
|
dbg(
|
|
982
2310
|
'background shell cap reached; it no longer holds the runtime open',
|
|
983
2311
|
shell.shellId,
|
|
984
2312
|
shell.description || '(no description)',
|
|
985
2313
|
);
|
|
986
2314
|
}
|
|
2315
|
+
if (expiredAny) publishBackgroundShellTasks();
|
|
987
2316
|
return backgroundShells.size() > 0;
|
|
988
2317
|
}
|
|
989
2318
|
|
|
990
2319
|
function evaluateLifecycle() {
|
|
991
2320
|
// A pending question card means a human is mid-answer; tearing the runtime
|
|
992
|
-
// down under them would discard the session the answer belongs to.
|
|
993
|
-
|
|
2321
|
+
// down under them would discard the session the answer belongs to. A turn
|
|
2322
|
+
// that is still publishing counts too: it settled, but its rows are live.
|
|
2323
|
+
if (disposed || activeTurn || publishingTurns.size > 0 || pendingHumanRequests > 0 || !client) return;
|
|
994
2324
|
if (!(idleShutdownMs > 0)) return;
|
|
995
2325
|
// Evaluated before the idle clock so the caps still expire on a runtime
|
|
996
2326
|
// that has been quiet far longer than the idle window.
|
|
@@ -1019,18 +2349,81 @@ export function createCopilotSdkSessionRunner({
|
|
|
1019
2349
|
return String(message?.providerModel || '').trim() || defaultModel;
|
|
1020
2350
|
}
|
|
1021
2351
|
|
|
1022
|
-
|
|
2352
|
+
/**
|
|
2353
|
+
* A queue row this turn owes an answer.
|
|
2354
|
+
*
|
|
2355
|
+
* `role`: 'primary' (the delivered row that opened the turn), 'steer' (a row
|
|
2356
|
+
* delivered mid-turn and pushed into the same runtime interaction) or
|
|
2357
|
+
* 'self' (the synthetic row of a self-initiated turn). An entry is owned —
|
|
2358
|
+
* heartbeat-claimed, crash-guard-requeued — from creation until `released`.
|
|
2359
|
+
*/
|
|
2360
|
+
function createEntry(turn, message, role) {
|
|
2361
|
+
const entry = {
|
|
2362
|
+
turn,
|
|
2363
|
+
message,
|
|
2364
|
+
role,
|
|
2365
|
+
// The id `send()` resolved; the key `user.message.messageId` matches.
|
|
2366
|
+
runtimeId: null,
|
|
2367
|
+
// A root `user.message` named this entry: the runtime has taken the
|
|
2368
|
+
// prompt. `delivery` is the runtime's own word for how.
|
|
2369
|
+
consumed: false,
|
|
2370
|
+
delivery: null,
|
|
2371
|
+
interactionId: null,
|
|
2372
|
+
// The entry whose run absorbed this one (`delivery:"steering"`).
|
|
2373
|
+
foldedInto: null,
|
|
2374
|
+
consumedMarked: false,
|
|
2375
|
+
// Its outcome was decided and its publishes started / it landed.
|
|
2376
|
+
settled: false,
|
|
2377
|
+
released: false,
|
|
2378
|
+
cancelled: false,
|
|
2379
|
+
// Something worth keeping was published on this row: the run's own
|
|
2380
|
+
// work (a tool line, a thought, a lane), or a carried-in line that
|
|
2381
|
+
// reports a failure — not bookkeeping, and not the other lines carried
|
|
2382
|
+
// in from between turns (`showsWork`). Decides whether an empty
|
|
2383
|
+
// continuation may vanish.
|
|
2384
|
+
showedWork: false,
|
|
2385
|
+
// `lastStreamedText` is what this row last received on /api/stream —
|
|
2386
|
+
// the stream gate and the abort/error fallback text both read it.
|
|
2387
|
+
state: { lastStreamedText: '' },
|
|
2388
|
+
};
|
|
2389
|
+
entry.done = new Promise((resolve) => { entry.resolveDone = resolve; });
|
|
2390
|
+
turn.entries.push(entry);
|
|
2391
|
+
return entry;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function assignRuntimeId(turn, entry, runtimeId) {
|
|
2395
|
+
const id = String(runtimeId || '').trim();
|
|
2396
|
+
entry.runtimeId = id || null;
|
|
2397
|
+
if (!id) return;
|
|
2398
|
+
turn.pushed.set(id, entry);
|
|
2399
|
+
// The runtime can start work on a prompt before the send() that pushed it
|
|
2400
|
+
// has resolved with its id. The boundary was remembered; reconcile it now,
|
|
2401
|
+
// on the dispatch chain so it orders after the events already captured.
|
|
2402
|
+
const unclaimed = turn.unclaimedUserMessages.get(id);
|
|
2403
|
+
if (!unclaimed) return;
|
|
2404
|
+
turn.unclaimedUserMessages.delete(id);
|
|
2405
|
+
dispatchChain = dispatchChain
|
|
2406
|
+
.then(() => adoptSegment(turn, entry, unclaimed))
|
|
2407
|
+
.catch((error) => { dbg('late prompt-boundary reconcile failed', error?.message || String(error)); });
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function createTurn(message, { kind = 'delivered', adoptEntry = null } = {}) {
|
|
1023
2411
|
const continuation = kind === 'continuation';
|
|
1024
2412
|
const turn = {
|
|
1025
2413
|
// 'delivered' (a queue row arrived) | 'continuation' (the runtime started
|
|
1026
2414
|
// work by itself and this worker minted a synthetic row for it).
|
|
1027
2415
|
kind,
|
|
1028
2416
|
message,
|
|
1029
|
-
|
|
1030
|
-
|
|
2417
|
+
model: '',
|
|
2418
|
+
// The runner's cross-turn knowledge of background agents rides in, so a
|
|
2419
|
+
// later turn's normalizer opens no lane for an agent spawned earlier.
|
|
2420
|
+
normalizer: createNormalizerImpl({ isBackgroundAgent: (agentId) => agentDetails.has(agentId) }),
|
|
1031
2421
|
result: null,
|
|
2422
|
+
failure: null,
|
|
1032
2423
|
settled: false,
|
|
1033
2424
|
aborted: false,
|
|
2425
|
+
// The row the user pressed Stop on (from the abort control), when known.
|
|
2426
|
+
abortedRowId: null,
|
|
1034
2427
|
stallTimer: null,
|
|
1035
2428
|
planBoardPosted: false,
|
|
1036
2429
|
// Set when a mutating tool was actually approved and run this turn.
|
|
@@ -1045,65 +2438,100 @@ export function createCopilotSdkSessionRunner({
|
|
|
1045
2438
|
// rather than failing the worker.
|
|
1046
2439
|
discarded: false,
|
|
1047
2440
|
controlState: null,
|
|
1048
|
-
// How many prompts have been SENT into this interaction. The runtime
|
|
1049
|
-
// consumes queued prompts in order, and opens one `user.message` segment
|
|
1050
|
-
// per prompt as it picks each up, so send order IS segment order.
|
|
1051
|
-
//
|
|
1052
|
-
// Deliberately not derived from the normalizer's live segment count: at
|
|
1053
|
-
// the moment a steering send happens the runtime may not have opened the
|
|
1054
|
-
// previous prompt's segment yet, and the steered row would then be
|
|
1055
|
-
// attributed the PREVIOUS prompt's answer.
|
|
1056
|
-
//
|
|
1057
|
-
// Starts at 1, not 0: this turn's own prompt is always its first, and
|
|
1058
|
-
// counting it here rather than after `send()` resolves closes the race
|
|
1059
|
-
// where a steering delivery lands between the two. A continuation sent no
|
|
1060
|
-
// prompt at all, so it starts at 0.
|
|
1061
|
-
promptsSent: continuation ? 0 : 1,
|
|
1062
|
-
// The segment this turn's OWN reply lands in. For a delivered turn that
|
|
1063
|
-
// is where its prompt is picked up — non-zero when a previous interaction
|
|
1064
|
-
// left prompts queued inside the runtime, which are consumed first. A
|
|
1065
|
-
// continuation has no `user.message` of its own, so the normalizer's
|
|
1066
|
-
// implicit first segment is its own.
|
|
1067
|
-
baseSegment: continuation ? 0 : carriedPrompts,
|
|
1068
|
-
// The segment the NEXT prompt steered into this interaction will be
|
|
1069
|
-
// answered in. Tracked explicitly rather than derived, because the two
|
|
1070
|
-
// kinds differ: a delivered turn's own prompt occupies `baseSegment`, so
|
|
1071
|
-
// the next is `baseSegment + 1`; a continuation occupies segment 0
|
|
1072
|
-
// without having sent anything, so the first steered prompt opens
|
|
1073
|
-
// segment 1. Getting this wrong cross-publishes the continuation's reply
|
|
1074
|
-
// into the user's row.
|
|
1075
|
-
nextSegmentIndex: continuation ? 1 : carriedPrompts + 1,
|
|
1076
|
-
// The lowest segment index any prompt SENT by this turn can occupy — the
|
|
1077
|
-
// floor for the carried-prompt arithmetic when the interaction ends.
|
|
1078
|
-
firstSentSegment: continuation ? 1 : carriedPrompts,
|
|
1079
2441
|
// Cancels any relay question card this turn is blocked on, so an aborted
|
|
1080
2442
|
// turn does not leave a human answering into the void.
|
|
1081
2443
|
abortController: new AbortController(),
|
|
1082
|
-
//
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
|
|
2444
|
+
// Cards (questions, ask-mode approvals, elicitations) open on this
|
|
2445
|
+
// turn's row; `humansIdle` resolves when the last one is done. A turn
|
|
2446
|
+
// that settles with one still open publishes only after it (driveTurn).
|
|
2447
|
+
humanRequests: 0,
|
|
2448
|
+
humansIdle: null,
|
|
2449
|
+
// Whether any card was ever raised on this turn's row.
|
|
2450
|
+
askedHuman: false,
|
|
2451
|
+
// Opened by `ensureInteractiveTurn` for a question rather than by the
|
|
2452
|
+
// runtime, and whether root work has joined it since.
|
|
2453
|
+
humanOrigin: false,
|
|
2454
|
+
rootWork: false,
|
|
2455
|
+
// Every row this turn owes an answer, in push order.
|
|
2456
|
+
entries: [],
|
|
2457
|
+
// runtime message id → entry, for the `user.message` acknowledgement.
|
|
2458
|
+
pushed: new Map(),
|
|
2459
|
+
// Normalizer segment index → the entry whose row the segment's text
|
|
2460
|
+
// belongs to. A `delivery:"steering"` prompt's segment belongs to the
|
|
2461
|
+
// run it was folded into; a `queued`/`idle` one starts its own run.
|
|
2462
|
+
segmentOwners: [],
|
|
2463
|
+
// The entry whose run the runtime is working on right now.
|
|
2464
|
+
currentOwner: null,
|
|
2465
|
+
primaryEntry: null,
|
|
2466
|
+
// Early settles (a previous run's row, published when the next run
|
|
2467
|
+
// opened) run off the event dispatch chain; driveTurn awaits them
|
|
2468
|
+
// before releasing ownership.
|
|
2469
|
+
settlePromises: [],
|
|
2470
|
+
// False until the turn-opening send() has resolved: the delivery hold.
|
|
2471
|
+
primarySent: continuation,
|
|
2472
|
+
// Root `user.message`s whose id no pushed entry knew yet (the event beat
|
|
2473
|
+
// the send() resolution): runtime id → boundary, reconciled by
|
|
2474
|
+
// assignRuntimeId.
|
|
2475
|
+
unclaimedUserMessages: new Map(),
|
|
1086
2476
|
};
|
|
2477
|
+
if (adoptEntry) {
|
|
2478
|
+
// A pushed row whose run the runtime opened after the turn it was pushed
|
|
2479
|
+
// into had already settled: it becomes the primary of a turn of its own.
|
|
2480
|
+
adoptEntry.turn = turn;
|
|
2481
|
+
turn.entries.push(adoptEntry);
|
|
2482
|
+
turn.primaryEntry = adoptEntry;
|
|
2483
|
+
if (adoptEntry.runtimeId) turn.pushed.set(adoptEntry.runtimeId, adoptEntry);
|
|
2484
|
+
turn.primarySent = true;
|
|
2485
|
+
} else {
|
|
2486
|
+
turn.primaryEntry = createEntry(turn, message, continuation ? 'self' : 'primary');
|
|
2487
|
+
}
|
|
2488
|
+
turn.currentOwner = turn.primaryEntry;
|
|
2489
|
+
if (continuation) turn.segmentOwners[0] = turn.primaryEntry;
|
|
2490
|
+
turn.primaryReady = new Promise((resolve) => { turn.resolvePrimaryReady = resolve; });
|
|
2491
|
+
if (turn.primarySent) turn.resolvePrimaryReady();
|
|
2492
|
+
if (continuation) {
|
|
2493
|
+
// The single in-flight registration for this turn's synthetic row, and
|
|
2494
|
+
// the signal that abandons it. One promise, stored ON the turn, so the
|
|
2495
|
+
// drive path and a late HTTP response reason about the SAME attempt —
|
|
2496
|
+
// registration and abandonment used to run blind of each other, and a
|
|
2497
|
+
// registration resolving after the local deadline would adopt a row into
|
|
2498
|
+
// a turn already torn down (audit #6).
|
|
2499
|
+
turn.registration = null;
|
|
2500
|
+
turn.registrationAbort = new AbortController();
|
|
2501
|
+
// Resolves `true` once the row exists, `false` once registration gave up
|
|
2502
|
+
// or was abandoned. Interactive handlers gate question creation on it: a
|
|
2503
|
+
// `user_input.requested` in the gap between "continuation opened" and
|
|
2504
|
+
// "row registered" would otherwise mint its card against no row id and
|
|
2505
|
+
// degrade to an unsupported answer (audit #7).
|
|
2506
|
+
turn.rowReady = new Promise((resolve) => { turn.resolveRowReady = resolve; });
|
|
2507
|
+
}
|
|
1087
2508
|
turn.done = new Promise((resolve, reject) => {
|
|
1088
2509
|
turn.resolveDone = resolve;
|
|
1089
2510
|
turn.rejectDone = reject;
|
|
1090
2511
|
});
|
|
1091
2512
|
// The stall watchdog and the runtime-exit observer can reject `turn.done`
|
|
1092
|
-
// before
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
// still sees the rejection.
|
|
2513
|
+
// before anything awaits it — an unhandled rejection that the worker crash
|
|
2514
|
+
// guard would escalate into a whole-worker failure. This handler exists
|
|
2515
|
+
// only to mark the promise as handled; driveTurn still sees the rejection.
|
|
1096
2516
|
turn.done.catch(() => {});
|
|
1097
2517
|
turn.settle = () => {
|
|
1098
2518
|
if (turn.settled) return;
|
|
1099
2519
|
turn.settled = true;
|
|
1100
2520
|
turn.disarmStall();
|
|
2521
|
+
// The terminal event releases the event stream at once — see
|
|
2522
|
+
// `beginPublishing`. The queue rows stay owned until the publishes land.
|
|
2523
|
+
beginPublishing(turn);
|
|
2524
|
+
turn.resolvePrimaryReady();
|
|
2525
|
+
syncDeliveryReadiness();
|
|
1101
2526
|
turn.resolveDone();
|
|
1102
2527
|
};
|
|
1103
2528
|
turn.fail = (error) => {
|
|
1104
2529
|
if (turn.settled) return;
|
|
1105
2530
|
turn.settled = true;
|
|
1106
2531
|
turn.disarmStall();
|
|
2532
|
+
beginPublishing(turn);
|
|
2533
|
+
turn.resolvePrimaryReady();
|
|
2534
|
+
syncDeliveryReadiness();
|
|
1107
2535
|
turn.rejectDone(error);
|
|
1108
2536
|
};
|
|
1109
2537
|
turn.disarmStall = () => {
|
|
@@ -1132,41 +2560,513 @@ export function createCopilotSdkSessionRunner({
|
|
|
1132
2560
|
return turn;
|
|
1133
2561
|
}
|
|
1134
2562
|
|
|
1135
|
-
|
|
2563
|
+
// ----------------------------------------------------- prompt boundaries --
|
|
2564
|
+
|
|
2565
|
+
function claimSegment(turn, index, owner) {
|
|
2566
|
+
// Leading unowned segments (text the runtime produced before any prompt
|
|
2567
|
+
// boundary, e.g. the answer to a prompt carried over from a previous
|
|
2568
|
+
// interaction) go to the first owner: that is where the merged note sent
|
|
2569
|
+
// the reader.
|
|
2570
|
+
for (let j = 0; j <= index; j += 1) {
|
|
2571
|
+
if (!turn.segmentOwners[j]) turn.segmentOwners[j] = owner;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
/**
|
|
2576
|
+
* A root `user.message` arrived: the runtime started work on a prompt. Called
|
|
2577
|
+
* on the dispatch chain right after the normalizer opened the prompt's
|
|
2578
|
+
* segment, so `promptCount() - 1` is that segment.
|
|
2579
|
+
*/
|
|
2580
|
+
async function noteUserMessage(turn, event) {
|
|
2581
|
+
const data = event?.data && typeof event.data === 'object' ? event.data : {};
|
|
2582
|
+
const index = Math.max(0, turn.normalizer.promptCount() - 1);
|
|
2583
|
+
const runtimeId = String(data.messageId || '').trim();
|
|
2584
|
+
const boundary = {
|
|
2585
|
+
index,
|
|
2586
|
+
delivery: String(data.delivery || '').trim().toLowerCase(),
|
|
2587
|
+
interactionId: String(data.interactionId || '').trim(),
|
|
2588
|
+
};
|
|
2589
|
+
const entry = runtimeId ? turn.pushed.get(runtimeId) : null;
|
|
2590
|
+
if (entry) {
|
|
2591
|
+
await adoptSegment(turn, entry, boundary);
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
// Not one of ours (yet): a prompt the runtime injected itself (an autopilot
|
|
2595
|
+
// nudge, a carried-over prompt from a previous interaction), or a pushed
|
|
2596
|
+
// prompt whose send() has not resolved with its id. Its text belongs to
|
|
2597
|
+
// the run in progress until proven otherwise.
|
|
2598
|
+
if (runtimeId && !turn.unclaimedUserMessages.has(runtimeId)) turn.unclaimedUserMessages.set(runtimeId, boundary);
|
|
2599
|
+
claimSegment(turn, index, turn.currentOwner || turn.primaryEntry);
|
|
2600
|
+
}
|
|
2601
|
+
|
|
2602
|
+
/**
|
|
2603
|
+
* Bind a prompt boundary to the entry it names.
|
|
2604
|
+
*
|
|
2605
|
+
* `delivery:"steering"`: the runtime folded the prompt into the run already
|
|
2606
|
+
* in progress — the reply that follows belongs to that run's row, and this
|
|
2607
|
+
* row will settle with the fold marker (Claude's `folded`). The relay is
|
|
2608
|
+
* told the prompt is consumed at once, so a crash between now and the
|
|
2609
|
+
* marker fails the row instead of re-running it.
|
|
2610
|
+
*
|
|
2611
|
+
* Anything else (`queued`, `idle`, unknown): the prompt starts its own run
|
|
2612
|
+
* on the same interaction. The previous run is over — nothing more will be
|
|
2613
|
+
* produced for its row, so it is settled right away rather than at the
|
|
2614
|
+
* drain's end, exactly as a Claude context finalizes when the CLI opens the
|
|
2615
|
+
* next one.
|
|
2616
|
+
*/
|
|
2617
|
+
async function adoptSegment(turn, entry, { index, delivery, interactionId }) {
|
|
2618
|
+
entry.consumed = true;
|
|
2619
|
+
noteCancellableChange();
|
|
2620
|
+
entry.delivery = delivery || null;
|
|
2621
|
+
entry.interactionId = interactionId || null;
|
|
2622
|
+
const running = turn.currentOwner || turn.primaryEntry;
|
|
2623
|
+
if (delivery === 'steering' && running && running !== entry && !running.settled) {
|
|
2624
|
+
entry.foldedInto = running;
|
|
2625
|
+
turn.segmentOwners[index] = running;
|
|
2626
|
+
claimSegment(turn, index, running);
|
|
2627
|
+
if (entry.role === 'steer' && !entry.consumedMarked) {
|
|
2628
|
+
entry.consumedMarked = true;
|
|
2629
|
+
void markConsumed(entry.message.conversationId, [entry.message]);
|
|
2630
|
+
}
|
|
2631
|
+
dbg('steered prompt folded into the running turn', entry.message.id, `→ ${running.message.id || '(continuation)'}`);
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
turn.segmentOwners[index] = entry;
|
|
2635
|
+
claimSegment(turn, index, entry);
|
|
2636
|
+
if (running === entry) return;
|
|
2637
|
+
turn.currentOwner = entry;
|
|
2638
|
+
if (running && !running.settled) {
|
|
2639
|
+
dbg('pushed prompt runs as its own turn; settling the previous run', entry.message.id, `after ${running.message.id || '(continuation)'}`);
|
|
2640
|
+
// Off the dispatch chain: a slow relay must not stall the events of
|
|
2641
|
+
// the run that just opened (or its terminator) behind this publish.
|
|
2642
|
+
// A continuation whose row is not minted yet (a steer pushed past the
|
|
2643
|
+
// hold after its wait ran out) settles once the row exists — publishing
|
|
2644
|
+
// now would name no row — or is discarded with it.
|
|
2645
|
+
const settled = running.message?.id || !turn.rowReady
|
|
2646
|
+
? settleEntry(turn, running, { type: 'completed' })
|
|
2647
|
+
: turn.rowReady.then((ready) => settleEntry(turn, running, { type: ready ? 'completed' : 'discarded' }));
|
|
2648
|
+
turn.settlePromises.push(settled);
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
// -------------------------------------------------------------- settling --
|
|
2653
|
+
|
|
2654
|
+
/**
|
|
2655
|
+
* Whether a completed row with no text has nothing to show at all: the
|
|
2656
|
+
* runtime opened it by itself (typically right after a background agent
|
|
2657
|
+
* finished), its run published no tool line, thought or lane, no card was
|
|
2658
|
+
* raised on it, no user message was folded into it, and nothing carried in
|
|
2659
|
+
* reported a failure. Such a row would only ever read "the turn completed
|
|
2660
|
+
* without a text reply", and the task card already showed the agent
|
|
2661
|
+
* finishing (live, gpt-5.6-luna, 2026-09-26). A failed agent's card is gone
|
|
2662
|
+
* by then, so the row carrying its failure line is kept (`showsWork`).
|
|
2663
|
+
* Every delivered row keeps the note — somebody asked something there.
|
|
2664
|
+
*/
|
|
2665
|
+
function settlesSilently(turn, entry, folded) {
|
|
2666
|
+
return turn.kind === 'continuation'
|
|
2667
|
+
&& entry.role === 'self'
|
|
2668
|
+
&& !turn.humanOrigin
|
|
2669
|
+
&& !turn.askedHuman
|
|
2670
|
+
&& !turn.planBoardPosted
|
|
2671
|
+
&& !entry.showedWork
|
|
2672
|
+
&& !folded.length
|
|
2673
|
+
&& Boolean(entry.message?.id);
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* Settle a silent continuation by tearing its row down: the requeue route's
|
|
2678
|
+
* continuation branch drops a processing continuation without an answer
|
|
2679
|
+
* (`dropped: 'continuation'`), the same teardown a dead worker's rows get.
|
|
2680
|
+
* A lost request leaves the row to that same stale-continuation recovery.
|
|
2681
|
+
*/
|
|
2682
|
+
async function dropSilentContinuation(entry) {
|
|
2683
|
+
dbg('continuation ended with nothing to show; dropping its row', entry.message.id);
|
|
2684
|
+
await api('POST', '/api/requeue', { messageId: entry.message.id, ...attemptFields(entry.message) })
|
|
2685
|
+
.catch((error) => { dbg('silent continuation drop failed', entry.message.id, error?.message || String(error)); });
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
/**
|
|
2689
|
+
* Decide and publish one row's outcome. Idempotent per entry. A run owner's
|
|
2690
|
+
* settle also settles the steers folded into its run (they were answered by
|
|
2691
|
+
* its reply), in push order, after the reply itself.
|
|
2692
|
+
*/
|
|
2693
|
+
async function settleEntry(turn, entry, outcome) {
|
|
2694
|
+
if (entry.settled) return;
|
|
2695
|
+
entry.settled = true;
|
|
2696
|
+
const model = turn.result?.model || turn.normalizer.model || turn.model || null;
|
|
2697
|
+
const folded = turn.entries.filter((other) => other !== entry && !other.settled && other.foldedInto === entry);
|
|
2698
|
+
try {
|
|
2699
|
+
switch (outcome.type) {
|
|
2700
|
+
case 'completed': {
|
|
2701
|
+
const text = finalTextFor(turn, entry, turn.result);
|
|
2702
|
+
if (!text && settlesSilently(turn, entry, folded)) {
|
|
2703
|
+
await dropSilentContinuation(entry);
|
|
2704
|
+
break;
|
|
2705
|
+
}
|
|
2706
|
+
const published = text || EMPTY_TURN_COMPLETION_NOTE;
|
|
2707
|
+
if (entry.role !== 'steer' && shouldPostPlanBoard({
|
|
2708
|
+
relayMode: entry.message.relayMode,
|
|
2709
|
+
finalText: text,
|
|
2710
|
+
alreadyPosted: turn.planBoardPosted,
|
|
2711
|
+
acted: turn.acted === true,
|
|
2712
|
+
})) {
|
|
2713
|
+
await publishPlanBoard(turn, text, 'plan-mode-fallback');
|
|
2714
|
+
}
|
|
2715
|
+
await publishFinalStream(entry.message, published);
|
|
2716
|
+
await publishResponse(entry.message, {
|
|
2717
|
+
text: published,
|
|
2718
|
+
model,
|
|
2719
|
+
consumedSteerIds: folded.map((other) => other.message),
|
|
2720
|
+
});
|
|
2721
|
+
break;
|
|
2722
|
+
}
|
|
2723
|
+
case 'aborted-by-user': {
|
|
2724
|
+
// The queue row's fate belongs to the server-side abort control,
|
|
2725
|
+
// exactly as in the Claude and Cursor workers. Publishing a response
|
|
2726
|
+
// here would double-settle the row.
|
|
2727
|
+
await publishFinalStream(entry.message, finalTextFor(turn, entry, turn.result));
|
|
2728
|
+
break;
|
|
2729
|
+
}
|
|
2730
|
+
case 'user-stopped': {
|
|
2731
|
+
// The run a Stop cut short while naming another row. The abort ack
|
|
2732
|
+
// fails only the row it names, so this one is failed here — as
|
|
2733
|
+
// stopped, never requeued: a requeue would re-run a prompt the user
|
|
2734
|
+
// stopped (the Claude worker's lingering-stopped rule). Claimed
|
|
2735
|
+
// first, so a crash meanwhile fails it rather than re-running it.
|
|
2736
|
+
const id = String(entry.message.id || '');
|
|
2737
|
+
const terminalError = buildRelayStopFailure();
|
|
2738
|
+
claimSettling(entry.message, 'stopped');
|
|
2739
|
+
try {
|
|
2740
|
+
await publishFinalStream(entry.message, finalTextFor(turn, entry, turn.result));
|
|
2741
|
+
const outcome = await publishResponse(entry.message, {
|
|
2742
|
+
text: terminalError.message,
|
|
2743
|
+
model,
|
|
2744
|
+
terminalError,
|
|
2745
|
+
requeueOnFailure: false,
|
|
2746
|
+
consumedSteerIds: folded.map((other) => other.message),
|
|
2747
|
+
});
|
|
2748
|
+
// Not even the stop landed: the heartbeat hands it to the relay to fail.
|
|
2749
|
+
if (outcome === 'failed' && id) settleFailedTerminals.set(id, terminalError);
|
|
2750
|
+
} finally {
|
|
2751
|
+
releaseSettling(id);
|
|
2752
|
+
}
|
|
2753
|
+
break;
|
|
2754
|
+
}
|
|
2755
|
+
case 'runtime-interrupted': {
|
|
2756
|
+
const own = finalTextFor(turn, entry, turn.result);
|
|
2757
|
+
const text = own ? `${own}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
|
|
2758
|
+
await publishFinalStream(entry.message, text);
|
|
2759
|
+
await publishResponse(entry.message, { text, model, consumedSteerIds: folded.map((other) => other.message) });
|
|
2760
|
+
break;
|
|
2761
|
+
}
|
|
2762
|
+
case 'error': {
|
|
2763
|
+
const { classified } = outcome;
|
|
2764
|
+
await publishFinalStream(entry.message, entry.state.lastStreamedText);
|
|
2765
|
+
await publishResponse(entry.message, {
|
|
2766
|
+
text: classified.text,
|
|
2767
|
+
model,
|
|
2768
|
+
terminalError: terminalErrorRecord(entry.message, classified),
|
|
2769
|
+
consumedSteerIds: folded.map((other) => other.message),
|
|
2770
|
+
});
|
|
2771
|
+
break;
|
|
2772
|
+
}
|
|
2773
|
+
case 'folded':
|
|
2774
|
+
await publishSettleMarker(entry.message, { text: STEER_FOLDED_TEXT, model, kind: 'folded', variant: 'folded' });
|
|
2775
|
+
break;
|
|
2776
|
+
case 'stopped':
|
|
2777
|
+
await publishSettleMarker(entry.message, { text: STEER_STOPPED_TEXT, model, kind: 'stopped', variant: 'stopped' });
|
|
2778
|
+
break;
|
|
2779
|
+
case 'merged-note':
|
|
2780
|
+
// Accepted by the runtime, never started: still queued inside it, so
|
|
2781
|
+
// it will be answered at the start of the next interaction — a
|
|
2782
|
+
// requeue would run it twice.
|
|
2783
|
+
await publishFinalStream(entry.message, STEERED_ROW_MERGED_NOTE);
|
|
2784
|
+
await publishResponse(entry.message, { text: STEERED_ROW_MERGED_NOTE, model });
|
|
2785
|
+
break;
|
|
2786
|
+
case 'cancelled':
|
|
2787
|
+
case 'discarded':
|
|
2788
|
+
default:
|
|
2789
|
+
break;
|
|
2790
|
+
}
|
|
2791
|
+
} catch (error) {
|
|
2792
|
+
dbg('settle publish failed', entry.message?.id || '(no id)', outcome.type, error?.message || String(error));
|
|
2793
|
+
} finally {
|
|
2794
|
+
entry.released = true;
|
|
2795
|
+
entry.resolveDone(true);
|
|
2796
|
+
}
|
|
2797
|
+
for (const other of folded) {
|
|
2798
|
+
const foldOutcome = outcome.type === 'completed' || outcome.type === 'merged-note'
|
|
2799
|
+
? { type: 'folded' }
|
|
2800
|
+
: outcome.type === 'error' ? outcome : { type: 'stopped' };
|
|
2801
|
+
await settleEntry(turn, other, foldOutcome);
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
/** Remove our unconsumed prompts from the runtime's queued lane (best effort). */
|
|
2806
|
+
async function clearOwnedQueuedItems(entries) {
|
|
2807
|
+
if (!entries.length || !session || !sessionRpc.queue) return;
|
|
2808
|
+
await refreshQueueLane();
|
|
2809
|
+
for (const entry of entries) {
|
|
2810
|
+
const itemId = queuedLane.get(entry.runtimeId);
|
|
2811
|
+
if (!itemId) continue;
|
|
2812
|
+
try {
|
|
2813
|
+
await session.rpc.queue.removeAt({ id: itemId });
|
|
2814
|
+
dbg('removed a stopped prompt from the runtime queue', entry.message.id);
|
|
2815
|
+
} catch (error) {
|
|
2816
|
+
dbg('queue.removeAt after Stop failed', entry.message.id, error?.message || String(error));
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
/**
|
|
2822
|
+
* Hold pushed-but-unstarted prompts for the orphan grace, then return the
|
|
2823
|
+
* ones the runtime still did not open (an opened one was adopted meanwhile
|
|
2824
|
+
* and belongs to a turn of its own now).
|
|
2825
|
+
*/
|
|
2826
|
+
async function settleAfterOrphanGrace(turn, orphans) {
|
|
2827
|
+
if (!orphans.length) return [];
|
|
2828
|
+
if (orphanGraceMs > 0) {
|
|
2829
|
+
dbg('pushed prompts not started at idle; waiting for the runtime to open them', orphans.map((entry) => entry.message.id).join(','));
|
|
2830
|
+
await sleep(orphanGraceMs);
|
|
2831
|
+
}
|
|
2832
|
+
return orphans.filter((entry) => entry.turn === turn && !entry.settled);
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
/**
|
|
2836
|
+
* Publish everything a finished turn still owes, per row.
|
|
2837
|
+
*
|
|
2838
|
+
* Shared by both turn kinds: the runtime does not distinguish a turn it
|
|
2839
|
+
* started from one it was asked for, so neither does the reporting.
|
|
2840
|
+
*/
|
|
2841
|
+
async function finishTurn(turn) {
|
|
2842
|
+
const result = turn.result;
|
|
2843
|
+
const failure = turn.failure;
|
|
2844
|
+
// Capture only. The POST fires from driveTurn's `finally`, after the rows
|
|
2845
|
+
// have been published.
|
|
2846
|
+
captureTurnUsage(turn.primaryEntry.message, result);
|
|
2847
|
+
|
|
2848
|
+
const pending = turn.entries.filter((entry) => !entry.settled && entry.turn === turn);
|
|
2849
|
+
if (!pending.length) return;
|
|
2850
|
+
|
|
2851
|
+
if (failure) {
|
|
2852
|
+
const classified = classifyCopilotTurnException(failure);
|
|
2853
|
+
dbg('copilot turn failed', turn.message?.id || '(continuation)', classified.detail);
|
|
2854
|
+
for (const entry of pending) {
|
|
2855
|
+
if (entry.settled) continue;
|
|
2856
|
+
await settleEntry(turn, entry, { type: 'error', classified });
|
|
2857
|
+
}
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
if (result?.isError) {
|
|
2861
|
+
const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
|
|
2862
|
+
dbg('turn failed', turn.message?.id || '(continuation)', classified.stableCode);
|
|
2863
|
+
for (const entry of pending) {
|
|
2864
|
+
if (entry.settled) continue;
|
|
2865
|
+
await settleEntry(turn, entry, { type: 'error', classified });
|
|
2866
|
+
}
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
const live = turn.currentOwner || turn.primaryEntry;
|
|
2871
|
+
// The row the user pressed Stop on. Normally the live one; the relay's
|
|
2872
|
+
// live-turn picker can also land on another row this turn owns, in which
|
|
2873
|
+
// case THAT row is left to the relay's abort control and the live row
|
|
2874
|
+
// keeps its partial reply. A Stop naming a row that is no longer pending
|
|
2875
|
+
// (its early settle already published, or it is no row of this turn's)
|
|
2876
|
+
// still cut the live run short — and nobody else will settle that row.
|
|
2877
|
+
const namedPending = turn.aborted && turn.abortedRowId
|
|
2878
|
+
? pending.find((entry) => String(entry.message?.id || '') === turn.abortedRowId) || null
|
|
2879
|
+
: null;
|
|
2880
|
+
const stopped = turn.aborted ? namedPending || (turn.abortedRowId ? null : live) : null;
|
|
2881
|
+
const cutRun = turn.aborted && !stopped && !live.settled ? live : null;
|
|
2882
|
+
const settleOne = async (entry) => {
|
|
2883
|
+
// A row that moved to a turn of its own (adoptOrphanedRun) is not ours.
|
|
2884
|
+
if (entry.turn !== turn || entry.settled) return;
|
|
2885
|
+
const ownsRun = entry === live || (!entry.foldedInto && entry.consumed);
|
|
2886
|
+
if (turn.aborted) {
|
|
2887
|
+
// A user Stop. The stopped row is settled server-side; every other
|
|
2888
|
+
// row pushed into the interaction went unanswered — the runtime
|
|
2889
|
+
// clears both its lanes on an interrupt — and says so, with Resend.
|
|
2890
|
+
if (entry === stopped) {
|
|
2891
|
+
dbg('turn aborted', entry.message.id);
|
|
2892
|
+
await settleEntry(turn, entry, { type: 'aborted-by-user' });
|
|
2893
|
+
} else if (entry === cutRun) {
|
|
2894
|
+
dbg('turn aborted by a Stop that named another row', entry.message.id, `named ${turn.abortedRowId}`);
|
|
2895
|
+
await settleEntry(turn, entry, { type: 'user-stopped' });
|
|
2896
|
+
} else if (ownsRun) {
|
|
2897
|
+
await settleEntry(turn, entry, { type: 'completed' });
|
|
2898
|
+
} else {
|
|
2899
|
+
await settleEntry(turn, entry, { type: 'stopped' });
|
|
2900
|
+
}
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
if (result?.aborted) {
|
|
2904
|
+
// The runtime interrupted the turn on its own. Nothing server-side is
|
|
2905
|
+
// waiting to settle these rows.
|
|
2906
|
+
if (entry === live) {
|
|
2907
|
+
dbg('turn interrupted by the runtime', entry.message.id);
|
|
2908
|
+
await settleEntry(turn, entry, { type: 'runtime-interrupted' });
|
|
2909
|
+
} else if (ownsRun) {
|
|
2910
|
+
await settleEntry(turn, entry, { type: 'completed' });
|
|
2911
|
+
} else {
|
|
2912
|
+
await settleEntry(turn, entry, { type: 'stopped' });
|
|
2913
|
+
}
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
if (ownsRun) {
|
|
2917
|
+
await settleEntry(turn, entry, { type: 'completed' });
|
|
2918
|
+
} else if (entry.foldedInto) {
|
|
2919
|
+
await settleEntry(turn, entry, { type: 'folded' });
|
|
2920
|
+
} else {
|
|
2921
|
+
await settleEntry(turn, entry, { type: 'merged-note' });
|
|
2922
|
+
}
|
|
2923
|
+
};
|
|
2924
|
+
// A normal end with pushed prompts the runtime never started: the runtime
|
|
2925
|
+
// gets a moment to open their runs (adoptOrphanedRun takes them) before
|
|
2926
|
+
// they are noted as carried over — AFTER the rows whose replies are
|
|
2927
|
+
// complete have been published, so a finished answer never waits on the
|
|
2928
|
+
// grace. Not on a Stop (the runtime cleared them) and not on an error.
|
|
2929
|
+
const orphans = (!turn.aborted && !result?.aborted)
|
|
2930
|
+
? pending.filter((entry) => entry.role === 'steer' && !entry.consumed && !entry.foldedInto)
|
|
2931
|
+
: [];
|
|
2932
|
+
if (turn.aborted || result?.aborted) {
|
|
2933
|
+
// Belt and braces: `interruptMainTurn({flushQueued:false})` and
|
|
2934
|
+
// `abort()` both empty the runtime's steering AND queued lanes
|
|
2935
|
+
// (probe-verified 2026-09-25), so nothing pushed runs after a Stop. Any
|
|
2936
|
+
// prompt of ours still listed in the queued lane is removed by id anyway
|
|
2937
|
+
// before its row is marked stopped. (The id-less steering lane cannot be
|
|
2938
|
+
// targeted, and needs no help.)
|
|
2939
|
+
await clearOwnedQueuedItems(pending.filter((entry) => !entry.consumed && entry.runtimeId));
|
|
2940
|
+
}
|
|
2941
|
+
// The stopped row first: settling it before the live row's cascade means
|
|
2942
|
+
// the cascade (which settles the steers folded into a run) cannot touch
|
|
2943
|
+
// the row the relay's abort control owns. A cut run goes first too, so
|
|
2944
|
+
// the steers folded into it settle through its cascade, after its stop.
|
|
2945
|
+
const first = stopped || cutRun;
|
|
2946
|
+
const ordered = first ? [first, ...pending.filter((entry) => entry !== first)] : pending;
|
|
2947
|
+
for (const entry of ordered) {
|
|
2948
|
+
if (!orphans.includes(entry)) await settleOne(entry);
|
|
2949
|
+
}
|
|
2950
|
+
for (const entry of await settleAfterOrphanGrace(turn, orphans)) {
|
|
2951
|
+
await settleOne(entry);
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
// ------------------------------------------------------------ lifecycle --
|
|
2956
|
+
|
|
2957
|
+
/**
|
|
2958
|
+
* The turn a Stop is for. Every turn's control poller runs until its
|
|
2959
|
+
* `driveTurn` finishes, so a turn that is still publishing can claim a Stop
|
|
2960
|
+
* meant for another: the Stop goes to the turn owning the row it names, and
|
|
2961
|
+
* one naming no owned row goes to the turn that is running.
|
|
2962
|
+
*/
|
|
2963
|
+
function stopTarget(turn, control) {
|
|
2964
|
+
const rowId = String(control?.queueMessageId || '').trim();
|
|
2965
|
+
if (rowId) {
|
|
2966
|
+
for (const candidate of [activeTurn, ...publishingTurns]) {
|
|
2967
|
+
if (candidate?.entries.some((entry) => !entry.released && String(entry.message?.id || '') === rowId)) {
|
|
2968
|
+
return candidate;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
return turn.settled && activeTurn ? activeTurn : turn;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
/**
|
|
2976
|
+
* Handle a Stop for `turn`. When the row it names settled early and its
|
|
2977
|
+
* reply is still publishing, the Stop resolves only once that reply has
|
|
2978
|
+
* landed: the relay's ack then fails the named row, which is a no-op on a
|
|
2979
|
+
* completed one but would overwrite a reply still in flight.
|
|
2980
|
+
*/
|
|
2981
|
+
async function interruptTurn(turn, control) {
|
|
2982
|
+
const rowId = String(control?.queueMessageId || '').trim() || null;
|
|
2983
|
+
const publishing = rowId
|
|
2984
|
+
? turn.entries.find((entry) => String(entry.message?.id || '') === rowId && entry.settled && !entry.released)
|
|
2985
|
+
: null;
|
|
2986
|
+
await interruptRun(turn, rowId);
|
|
2987
|
+
if (publishing) await withTimeout(publishing.done, STOPPED_ROW_PUBLISH_WAIT_MS, false);
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
/**
|
|
2991
|
+
* Interrupt the turn the user stopped. `interruptMainTurn` (when the
|
|
2992
|
+
* runtime has it) stops the main agent loop and nothing else: background
|
|
2993
|
+
* agents and promoted shells keep running under their own Stop, exactly
|
|
2994
|
+
* like the Claude worker's targeted interrupt. `session.abort()` — the
|
|
2995
|
+
* fallback — also cancels every background agent and clears both message
|
|
2996
|
+
* lanes. Either way the runtime answers with an aborted idle event, which
|
|
2997
|
+
* settles the turn through the normal terminator.
|
|
2998
|
+
*/
|
|
2999
|
+
async function interruptRun(turn, rowId) {
|
|
3000
|
+
if (turn.settled) {
|
|
3001
|
+
// Its run is over and the main loop may already belong to the next
|
|
3002
|
+
// turn, so nothing is interrupted. While it waits on a card (driveTurn)
|
|
3003
|
+
// the Stop ends the cards, and the row settles as stopped.
|
|
3004
|
+
if (turn.humanRequests > 0) {
|
|
3005
|
+
turn.aborted = true;
|
|
3006
|
+
turn.abortedRowId = rowId;
|
|
3007
|
+
turn.abortController.abort();
|
|
3008
|
+
}
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
turn.aborted = true;
|
|
3012
|
+
turn.abortedRowId = rowId;
|
|
3013
|
+
// While `ensureSession` is still connecting there is no session to abort
|
|
3014
|
+
// and this would be a silent no-op; `runTurn` re-checks `turn.aborted`
|
|
3015
|
+
// once the session exists and settles there instead.
|
|
3016
|
+
if (!session) return;
|
|
3017
|
+
if (sessionRpc.interruptMainTurn) {
|
|
3018
|
+
try {
|
|
3019
|
+
const outcome = await session.rpc.interruptMainTurn({ flushQueued: false });
|
|
3020
|
+
if (outcome?.interrupted === false) {
|
|
3021
|
+
// Nothing was processing: no aborted idle will come. The turn is
|
|
3022
|
+
// over as far as the runtime is concerned.
|
|
3023
|
+
dbg('interruptMainTurn found no main turn in flight; settling locally', turn.message?.id || '');
|
|
3024
|
+
turn.settle();
|
|
3025
|
+
}
|
|
3026
|
+
return;
|
|
3027
|
+
} catch (error) {
|
|
3028
|
+
dbg('interruptMainTurn failed; falling back to abort()', error?.message || String(error));
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
await session.abort?.();
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
/**
|
|
3035
|
+
* Start a delivered turn: bring the session up, send the prompt, and hand
|
|
3036
|
+
* the turn's lifecycle to `driveTurn`. Resolves when THIS row has settled —
|
|
3037
|
+
* which can be before the turn ends, when a message pushed later ran as its
|
|
3038
|
+
* own run and this one's reply was already complete.
|
|
3039
|
+
*/
|
|
3040
|
+
async function runTurn(turn) {
|
|
3041
|
+
const { message } = turn;
|
|
3042
|
+
const entry = turn.primaryEntry;
|
|
1136
3043
|
const model = resolvePerTurnModel(message);
|
|
3044
|
+
// The dequeued row's reasoning effort (audit #9). Normalisation happens in
|
|
3045
|
+
// the switcher: `none`/absent mean "the model's default".
|
|
3046
|
+
const effort = message?.reasoningEffort ?? null;
|
|
1137
3047
|
const relayMode = message?.relayMode || 'agent';
|
|
1138
3048
|
lastRelayMode = relayMode;
|
|
1139
|
-
|
|
3049
|
+
turn.model = model;
|
|
1140
3050
|
// Set before the session is touched: the heartbeat's owner-recovery guard
|
|
1141
3051
|
// reads the active ids, so a cold-start delivery must already own its row.
|
|
1142
3052
|
activeTurn = turn;
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
// `assistant.turn_end` → `session.idle{aborted:true}`, which settles
|
|
1154
|
-
// the turn through the normal terminator; this only asks for it.
|
|
1155
|
-
await session.abort?.();
|
|
1156
|
-
},
|
|
1157
|
-
});
|
|
1158
|
-
|
|
3053
|
+
// The delivery hold begins: nothing steers in until the prompt is sent.
|
|
3054
|
+
syncDeliveryReadiness();
|
|
3055
|
+
// Session-wide, not per row: the client's Stop targets whichever of this
|
|
3056
|
+
// turn's rows is live, and the relay only hands out controls for rows
|
|
3057
|
+
// this worker owns.
|
|
3058
|
+
turn.controlState = controlPoller?.start?.({
|
|
3059
|
+
queueMessageId: '',
|
|
3060
|
+
onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
|
|
3061
|
+
}) || null;
|
|
3062
|
+
turn.drive = driveTurn(turn);
|
|
1159
3063
|
try {
|
|
1160
|
-
await ensureSession(model,
|
|
3064
|
+
await ensureSession(model, effort);
|
|
1161
3065
|
if (turn.aborted) {
|
|
1162
3066
|
// The abort landed while the session was still being built. Nothing
|
|
1163
|
-
// was sent, so there is no runtime turn to interrupt —
|
|
1164
|
-
//
|
|
1165
|
-
// settle locally rather than sending a prompt the user just cancelled.
|
|
3067
|
+
// was sent, so there is no runtime turn to interrupt — settle locally
|
|
3068
|
+
// rather than sending a prompt the user just cancelled.
|
|
1166
3069
|
dbg('turn aborted before send', message.id);
|
|
1167
|
-
try { await session?.abort?.(); } catch (error) {
|
|
1168
|
-
dbg('abort during session setup failed', error?.message || String(error));
|
|
1169
|
-
}
|
|
1170
3070
|
turn.settle();
|
|
1171
3071
|
} else {
|
|
1172
3072
|
turn.armStall();
|
|
@@ -1174,27 +3074,152 @@ export function createCopilotSdkSessionRunner({
|
|
|
1174
3074
|
const { prompt: body, attachments } = buildMessageOptionsImpl(message);
|
|
1175
3075
|
// Relay mode marker + (on a mode change) the standing mode instructions,
|
|
1176
3076
|
// the relay tool guidance and the live preview-lane block.
|
|
1177
|
-
const
|
|
1178
|
-
const prompt = withRelayContext(prefix, body);
|
|
3077
|
+
const context = await buildRelayContextPrefix(message).catch(() => null);
|
|
3078
|
+
const prompt = withRelayContext(context?.prefix, body);
|
|
1179
3079
|
// `mode` and `attachments` are FIELDS of the single MessageOptions
|
|
1180
3080
|
// argument — `send()` takes no second parameter, so passing options
|
|
1181
3081
|
// positionally drops them silently.
|
|
1182
|
-
|
|
3082
|
+
const sending = session.send({
|
|
1183
3083
|
prompt,
|
|
1184
3084
|
...(attachments?.length ? { attachments } : {}),
|
|
1185
3085
|
...(sendMode ? { mode: sendMode } : {}),
|
|
1186
3086
|
agentMode: copilotAgentModeForRelayMode(relayMode),
|
|
1187
3087
|
});
|
|
1188
|
-
//
|
|
1189
|
-
// the
|
|
1190
|
-
|
|
3088
|
+
// A send that never resolves (a wedged runtime) must not hold this
|
|
3089
|
+
// delivery hostage once the watchdog has failed the turn: the id is
|
|
3090
|
+
// still recorded if it ever arrives, but the wait ends with the turn.
|
|
3091
|
+
const settledFirst = Symbol('turn-settled');
|
|
3092
|
+
const runtimeId = await Promise.race([
|
|
3093
|
+
sending,
|
|
3094
|
+
turn.done.then(() => settledFirst, () => settledFirst),
|
|
3095
|
+
]);
|
|
3096
|
+
if (runtimeId === settledFirst) {
|
|
3097
|
+
sending.then((id) => assignRuntimeId(turn, entry, id)).catch(() => {});
|
|
3098
|
+
} else {
|
|
3099
|
+
assignRuntimeId(turn, entry, runtimeId);
|
|
3100
|
+
// Committed only now that `send()` accepted the prompt (audit #32): a
|
|
3101
|
+
// failed send means the runtime never READ the mode guidance, and
|
|
3102
|
+
// committing before it would make the same-mode retry omit it.
|
|
3103
|
+
context?.commit();
|
|
3104
|
+
// The runtime has the prompt: the delivery hold ends and later
|
|
3105
|
+
// deliveries steer into this turn.
|
|
3106
|
+
turn.primarySent = true;
|
|
3107
|
+
turn.resolvePrimaryReady();
|
|
3108
|
+
syncDeliveryReadiness();
|
|
3109
|
+
}
|
|
1191
3110
|
}
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
|
-
|
|
3111
|
+
} catch (error) {
|
|
3112
|
+
// A failure that killed the session (or came from starting it) leaves a
|
|
3113
|
+
// handle nothing else can use; drop it so the next delivery rebuilds and
|
|
3114
|
+
// resumes rather than sending into a dead runtime. An UNCONFIRMED MODEL
|
|
3115
|
+
// SWITCH is the exception: the session is healthy and merely still on
|
|
3116
|
+
// its previous model, and tearing the runtime down over it would kill
|
|
3117
|
+
// any live background work for a selection problem the user fixes by
|
|
3118
|
+
// picking another model.
|
|
3119
|
+
if (!isModelSwitchUnconfirmedError(error)) {
|
|
3120
|
+
await stopRuntime('turn-failure').catch(() => {});
|
|
3121
|
+
}
|
|
3122
|
+
turn.fail(error);
|
|
1195
3123
|
}
|
|
3124
|
+
// `send()` resolving is NOT the turn's completion; the event stream is.
|
|
3125
|
+
await awaitEntry(turn, entry);
|
|
3126
|
+
return true;
|
|
3127
|
+
}
|
|
1196
3128
|
|
|
1197
|
-
|
|
3129
|
+
/**
|
|
3130
|
+
* Wait for a row's settle. A row's settle is not necessarily the turn's
|
|
3131
|
+
* end: a prompt pushed later may still be running on the same interaction,
|
|
3132
|
+
* and this delivery must not wait for it. But when the turn IS over, wait
|
|
3133
|
+
* for its wrap-up too (ownership release, usage ingest), so a caller that
|
|
3134
|
+
* awaited the delivery sees a quiescent runner — the contract every test
|
|
3135
|
+
* and the crash guard rely on. `driveTurn` never rejects.
|
|
3136
|
+
*/
|
|
3137
|
+
async function awaitEntry(turn, entry) {
|
|
3138
|
+
await entry.done;
|
|
3139
|
+
if (turn.settled && turn.drive) await turn.drive.catch(() => {});
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
/**
|
|
3143
|
+
* Run a turn from its first event to its last publish.
|
|
3144
|
+
*
|
|
3145
|
+
* Row ownership is released only after every publish has landed: a
|
|
3146
|
+
* heartbeat firing inside the publish window with no active ids would tell
|
|
3147
|
+
* the relay this worker owns nothing, and the still-`processing` rows would
|
|
3148
|
+
* be recovered underneath it. (The EVENT stream was already released at the
|
|
3149
|
+
* terminator — see `beginPublishing` — so the runtime's next self-initiated
|
|
3150
|
+
* turn can open while this one is still writing.)
|
|
3151
|
+
*/
|
|
3152
|
+
async function driveTurn(turn) {
|
|
3153
|
+
try {
|
|
3154
|
+
await turn.done;
|
|
3155
|
+
} catch (error) {
|
|
3156
|
+
turn.failure = turn.failure || error;
|
|
3157
|
+
}
|
|
3158
|
+
// A card still open on this row — a background agent asking while the
|
|
3159
|
+
// main loop went idle (a root question cannot get here: the loop is
|
|
3160
|
+
// blocked on its handler) — keeps the row processing until it is done:
|
|
3161
|
+
// the relay opens cards only on a processing row, and `/api/response`
|
|
3162
|
+
// cancels the ones still open. The event stream is already released
|
|
3163
|
+
// (`beginPublishing`), so new root work opens a turn of its own meanwhile.
|
|
3164
|
+
if (!turn.aborted && !turn.failure && turn.humanRequests > 0) await turn.humansIdle;
|
|
3165
|
+
try {
|
|
3166
|
+
await quiesceTurn(turn);
|
|
3167
|
+
if (turn.failure && turn.kind === 'delivered' && !isModelSwitchUnconfirmedError(turn.failure) && session) {
|
|
3168
|
+
// A stall or a runtime death mid-turn (a setup failure already tore
|
|
3169
|
+
// the runtime down in runTurn).
|
|
3170
|
+
await stopRuntime('turn-failure').catch(() => {});
|
|
3171
|
+
}
|
|
3172
|
+
if (turn.kind === 'continuation') {
|
|
3173
|
+
// Nothing can be published before the row exists. The buffer is
|
|
3174
|
+
// drained by the registration itself; this only covers actions that
|
|
3175
|
+
// arrived during the drain.
|
|
3176
|
+
await awaitContinuationRow(turn);
|
|
3177
|
+
// The invariant is the id, not the flag: registration can also give up
|
|
3178
|
+
// by throwing, or by taking longer than `awaitContinuationRow` waits,
|
|
3179
|
+
// and publishing against `messageId: null` would attribute the whole
|
|
3180
|
+
// turn to nothing at all.
|
|
3181
|
+
if (!turn.message.id) {
|
|
3182
|
+
if (!turn.discarded) abandonContinuationRegistration(turn, 'no relay row at publish time');
|
|
3183
|
+
dbg('continuation output dropped (no relay row)');
|
|
3184
|
+
const classified = classifyCopilotTurnException(
|
|
3185
|
+
turn.failure || new Error('the relay refused a continuation row for this turn'),
|
|
3186
|
+
);
|
|
3187
|
+
for (const entry of turn.entries) {
|
|
3188
|
+
if (entry.settled || entry.turn !== turn) continue;
|
|
3189
|
+
// The steering path is still waiting on any row it handed us, and
|
|
3190
|
+
// it has no row of its own to fall back to.
|
|
3191
|
+
await settleEntry(turn, entry, entry.role === 'self' ? { type: 'discarded' } : { type: 'error', classified });
|
|
3192
|
+
}
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
await flushBufferedActions(turn);
|
|
3196
|
+
}
|
|
3197
|
+
await finishTurn(turn);
|
|
3198
|
+
} catch (error) {
|
|
3199
|
+
dbg('turn publish failed', error?.message || String(error));
|
|
3200
|
+
} finally {
|
|
3201
|
+
// Early settles still in flight must land before the rows stop being
|
|
3202
|
+
// reported (settleEntry never rejects, but this is the ordering guard).
|
|
3203
|
+
await Promise.allSettled(turn.settlePromises);
|
|
3204
|
+
controlPoller?.stop?.(turn.controlState);
|
|
3205
|
+
turn.controlState = null;
|
|
3206
|
+
// Every path through the turn — published, failed, aborted, threw — has
|
|
3207
|
+
// finished by here. Anything still unresolved is released so no caller
|
|
3208
|
+
// waits forever; its row is left to the relay's recovery.
|
|
3209
|
+
for (const entry of turn.entries) {
|
|
3210
|
+
if (entry.turn !== turn || entry.released) continue;
|
|
3211
|
+
entry.settled = true;
|
|
3212
|
+
entry.released = true;
|
|
3213
|
+
entry.resolveDone(true);
|
|
3214
|
+
}
|
|
3215
|
+
// Released only once every publish for this turn's rows has landed.
|
|
3216
|
+
releaseTurnOwnership(turn);
|
|
3217
|
+
touch();
|
|
3218
|
+
syncDeliveryReadiness();
|
|
3219
|
+
// The ingest is advisory, never awaited, and must never be able to
|
|
3220
|
+
// delay a reply that is already written.
|
|
3221
|
+
postTurnUsage();
|
|
3222
|
+
}
|
|
1198
3223
|
}
|
|
1199
3224
|
|
|
1200
3225
|
/**
|
|
@@ -1216,111 +3241,6 @@ export function createCopilotSdkSessionRunner({
|
|
|
1216
3241
|
await closeStraySubagentRuns(turn).catch(() => {});
|
|
1217
3242
|
}
|
|
1218
3243
|
|
|
1219
|
-
/**
|
|
1220
|
-
* Publish a finished turn's outcome onto its queue row (and every row steered
|
|
1221
|
-
* into it).
|
|
1222
|
-
*
|
|
1223
|
-
* Shared by both turn kinds. A continuation reaches it through
|
|
1224
|
-
* `driveContinuation` instead of `runTurn`, but the outcomes are identical:
|
|
1225
|
-
* the runtime does not distinguish a turn it started from one it was asked
|
|
1226
|
-
* for, so neither does the reporting.
|
|
1227
|
-
*/
|
|
1228
|
-
async function finishTurn(turn, model) {
|
|
1229
|
-
const { message } = turn;
|
|
1230
|
-
const result = turn.result;
|
|
1231
|
-
const responseModel = result?.model || turn.normalizer.model || model || null;
|
|
1232
|
-
// Capture only. The POST fires from `handlePendingPayload`'s `finally`,
|
|
1233
|
-
// after this row has been published.
|
|
1234
|
-
captureTurnUsage(message, result);
|
|
1235
|
-
|
|
1236
|
-
// Whatever this interaction did not get to stays queued in the runtime and
|
|
1237
|
-
// shifts the NEXT interaction's segments: the prompts this turn sent occupy
|
|
1238
|
-
// `[firstSentSegment, nextSegmentIndex)`, and the ones the runtime never
|
|
1239
|
-
// opened a segment for are still in its pending queue.
|
|
1240
|
-
//
|
|
1241
|
-
// The `max` against `firstSentSegment` is what makes this correct for a
|
|
1242
|
-
// continuation, which occupies segment 0 without having sent anything: a
|
|
1243
|
-
// continuation that opened no segment at all (an empty self-initiated turn)
|
|
1244
|
-
// would otherwise report one carried prompt and shift the next real turn's
|
|
1245
|
-
// answer by one.
|
|
1246
|
-
const segmentsOpened = turn.normalizer.promptCount();
|
|
1247
|
-
carriedPrompts = Math.max(0, turn.nextSegmentIndex - Math.max(turn.firstSentSegment, segmentsOpened));
|
|
1248
|
-
|
|
1249
|
-
// This row's own reply. Falls back to the whole composed text when the
|
|
1250
|
-
// runtime opened no segment at all (an empty or immediately-failed turn),
|
|
1251
|
-
// which is strictly better than publishing nothing.
|
|
1252
|
-
const ownText = String(
|
|
1253
|
-
turn.normalizer.segmentText(turn.baseSegment) || result?.text || turn.state.lastStreamedText || '',
|
|
1254
|
-
).trim();
|
|
1255
|
-
|
|
1256
|
-
// A user-initiated abort publishes the partial text and nothing else: the
|
|
1257
|
-
// queue row's fate belongs to the server-side abort control, exactly as in
|
|
1258
|
-
// the Claude and Cursor workers. Publishing a response here would
|
|
1259
|
-
// double-settle the row.
|
|
1260
|
-
if (turn.aborted) {
|
|
1261
|
-
dbg('turn aborted', message.id);
|
|
1262
|
-
// This row's segment only: the steered rows publish their own, and
|
|
1263
|
-
// publishing the composed text here would show their replies twice.
|
|
1264
|
-
await publishFinalStream(message, ownText);
|
|
1265
|
-
// A steered row is NOT covered by the abort control (which knows only
|
|
1266
|
-
// about the row the user aborted), so it still has to be settled here or
|
|
1267
|
-
// it holds `processing` forever behind a renewing lease.
|
|
1268
|
-
await settleSteeredRows(turn, result, responseModel);
|
|
1269
|
-
return true;
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
// The runtime aborted on its own (`result.aborted` with no relay-side
|
|
1273
|
-
// abort control in flight). Nothing server-side is waiting to settle this
|
|
1274
|
-
// row, so returning here would leave it pending until the delivery
|
|
1275
|
-
// watchdog fails it with a misleading "Relay timeout" — the row has to be
|
|
1276
|
-
// settled with a record that says what actually happened.
|
|
1277
|
-
if (result?.aborted) {
|
|
1278
|
-
dbg('turn interrupted by the runtime', message.id);
|
|
1279
|
-
const text = ownText ? `${ownText}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
|
|
1280
|
-
await publishFinalStream(message, text);
|
|
1281
|
-
await publishResponse(message, { text, model: responseModel });
|
|
1282
|
-
await settleSteeredRows(turn, result, responseModel);
|
|
1283
|
-
return true;
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
if (result?.isError) {
|
|
1287
|
-
const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
|
|
1288
|
-
dbg('turn failed', message.id, classified.stableCode);
|
|
1289
|
-
await publishFinalStream(message, turn.state.lastStreamedText);
|
|
1290
|
-
await publishResponse(message, {
|
|
1291
|
-
text: classified.text,
|
|
1292
|
-
model: responseModel,
|
|
1293
|
-
terminalError: terminalErrorRecord(message, classified),
|
|
1294
|
-
});
|
|
1295
|
-
// The steered rows failed with it — they were being answered by the same
|
|
1296
|
-
// interaction. They get the same terminal record so each row says why.
|
|
1297
|
-
await settleSteeredRows(turn, result, responseModel, classified);
|
|
1298
|
-
return true;
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
// The turn produced a plan but never called exit-plan-mode (or the runtime
|
|
1302
|
-
// build has no such hook). Same text-shape fallback the siblings use, and
|
|
1303
|
-
// it must go out BEFORE the response: `/api/relay-board` 409s once the
|
|
1304
|
-
// queue row leaves `processing`.
|
|
1305
|
-
if (shouldPostPlanBoard({
|
|
1306
|
-
relayMode: message.relayMode,
|
|
1307
|
-
finalText: ownText,
|
|
1308
|
-
alreadyPosted: turn.planBoardPosted,
|
|
1309
|
-
acted: turn.acted === true,
|
|
1310
|
-
})) {
|
|
1311
|
-
await publishPlanBoard(turn, ownText, 'plan-mode-fallback');
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
// A terminal, non-error turn with no prose is COMPLETE, not a failed
|
|
1315
|
-
// delivery — requeuing re-runs deterministically empty work until the
|
|
1316
|
-
// retry cap fails the row with a misleading "Relay timeout".
|
|
1317
|
-
const publishedText = ownText || EMPTY_TURN_COMPLETION_NOTE;
|
|
1318
|
-
await publishFinalStream(message, publishedText);
|
|
1319
|
-
await publishResponse(message, { text: publishedText, model: responseModel });
|
|
1320
|
-
await settleSteeredRows(turn, result, responseModel);
|
|
1321
|
-
return true;
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
3244
|
// ----------------------------------------------------------- continuation --
|
|
1325
3245
|
|
|
1326
3246
|
/**
|
|
@@ -1349,36 +3269,94 @@ export function createCopilotSdkSessionRunner({
|
|
|
1349
3269
|
// The gap this pin covers has closed.
|
|
1350
3270
|
continuationDueSince = 0;
|
|
1351
3271
|
turn.armStall();
|
|
1352
|
-
// Lines produced between turns (a settled shell's
|
|
1353
|
-
// the turn they triggered.
|
|
3272
|
+
// Lines produced between turns (a settled shell's or background agent's
|
|
3273
|
+
// notification) belong to the turn they triggered.
|
|
1354
3274
|
if (pendingActivities.length) {
|
|
1355
|
-
for (const text of pendingActivities.splice(0)) {
|
|
1356
|
-
turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null } });
|
|
3275
|
+
for (const { text, reportsFailure } of pendingActivities.splice(0)) {
|
|
3276
|
+
turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null }, carried: true, reportsFailure });
|
|
1357
3277
|
}
|
|
1358
3278
|
}
|
|
1359
3279
|
dbg('opening a continuation turn for runtime-initiated work');
|
|
3280
|
+
syncDeliveryReadiness();
|
|
1360
3281
|
// Both are fire-and-forget by design (the SDK's event callback is
|
|
1361
3282
|
// synchronous and cannot await a turn), so both must swallow: an unhandled
|
|
1362
3283
|
// rejection here would reach the worker crash guard and take the whole
|
|
1363
|
-
// process down over one lost continuation.
|
|
1364
|
-
|
|
3284
|
+
// process down over one lost continuation. The caught chain is what lives
|
|
3285
|
+
// on the turn, so `awaitContinuationRow` can race it without re-handling.
|
|
3286
|
+
turn.registration = registerContinuationRow(turn).catch((error) => {
|
|
1365
3287
|
dbg('continuation registration threw', error?.message || String(error));
|
|
1366
3288
|
if (turn.message.id) {
|
|
1367
3289
|
// The row was created and only the bookkeeping after it failed. Release
|
|
1368
3290
|
// the buffer to the drive path rather than throwing away a turn that
|
|
1369
3291
|
// has somewhere to go.
|
|
1370
3292
|
turn.registered = true;
|
|
3293
|
+
turn.resolveRowReady?.(true);
|
|
3294
|
+
syncDeliveryReadiness();
|
|
1371
3295
|
return;
|
|
1372
3296
|
}
|
|
1373
|
-
turn
|
|
1374
|
-
turn.bufferedActions = [];
|
|
3297
|
+
abandonContinuationRegistration(turn, 'registration threw');
|
|
1375
3298
|
});
|
|
1376
|
-
|
|
3299
|
+
turn.drive = driveTurn(turn);
|
|
3300
|
+
turn.drive.catch((error) => {
|
|
1377
3301
|
dbg('continuation driver threw', error?.message || String(error));
|
|
1378
3302
|
});
|
|
1379
3303
|
return turn;
|
|
1380
3304
|
}
|
|
1381
3305
|
|
|
3306
|
+
/**
|
|
3307
|
+
* A pushed row whose run the runtime opened AFTER the turn it was pushed
|
|
3308
|
+
* into had settled (the drain's idle raced the send). It is not a
|
|
3309
|
+
* self-initiated turn — it is that row's own turn, and its reply belongs on
|
|
3310
|
+
* that row. Returns the new turn, or null when the id is not one of ours.
|
|
3311
|
+
*/
|
|
3312
|
+
function adoptOrphanedRun(event) {
|
|
3313
|
+
if (String(event?.type || '') !== 'user.message' || event?.agentId) return null;
|
|
3314
|
+
const runtimeId = String(event?.data?.messageId || '').trim();
|
|
3315
|
+
if (!runtimeId) return null;
|
|
3316
|
+
for (const previous of publishingTurns) {
|
|
3317
|
+
const entry = previous.pushed.get(runtimeId);
|
|
3318
|
+
if (!entry || entry.consumed || entry.settled) continue;
|
|
3319
|
+
previous.entries = previous.entries.filter((other) => other !== entry);
|
|
3320
|
+
previous.pushed.delete(runtimeId);
|
|
3321
|
+
// From here the entry belongs to the new turn (`entry.turn`); the old
|
|
3322
|
+
// turn's settle passes skip rows that moved away.
|
|
3323
|
+
const turn = createTurn(entry.message, { kind: 'delivered', adoptEntry: entry });
|
|
3324
|
+
turn.model = resolvePerTurnModel(entry.message);
|
|
3325
|
+
activeTurn = turn;
|
|
3326
|
+
turn.armStall();
|
|
3327
|
+
turn.controlState = controlPoller?.start?.({
|
|
3328
|
+
queueMessageId: '',
|
|
3329
|
+
onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
|
|
3330
|
+
}) || null;
|
|
3331
|
+
dbg('a pushed prompt opened its own run after its turn settled; adopting it', entry.message.id);
|
|
3332
|
+
syncDeliveryReadiness();
|
|
3333
|
+
turn.drive = driveTurn(turn);
|
|
3334
|
+
turn.drive.catch((error) => {
|
|
3335
|
+
dbg('adopted turn driver threw', error?.message || String(error));
|
|
3336
|
+
});
|
|
3337
|
+
return turn;
|
|
3338
|
+
}
|
|
3339
|
+
return null;
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
/**
|
|
3343
|
+
* Give up on a continuation's synthetic row: nothing buffered will ever
|
|
3344
|
+
* publish, and a registration still in flight must not adopt a row into this
|
|
3345
|
+
* turn when it finally answers. The one place all three abandonment paths
|
|
3346
|
+
* (retries exhausted, local deadline expired, registration threw) converge,
|
|
3347
|
+
* so none of them can forget the abort signal or leave `rowReady` hanging.
|
|
3348
|
+
*/
|
|
3349
|
+
function abandonContinuationRegistration(turn, reason) {
|
|
3350
|
+
turn.discarded = true;
|
|
3351
|
+
turn.bufferedActions = [];
|
|
3352
|
+
turn.registrationAbort?.abort?.();
|
|
3353
|
+
// Interactive handlers stop waiting and take their degraded path, and the
|
|
3354
|
+
// delivery hold that waited for the row ends.
|
|
3355
|
+
turn.resolveRowReady?.(false);
|
|
3356
|
+
syncDeliveryReadiness();
|
|
3357
|
+
dbg('continuation registration abandoned:', reason);
|
|
3358
|
+
}
|
|
3359
|
+
|
|
1382
3360
|
/**
|
|
1383
3361
|
* Create the synthetic queue row and release the turn's buffered output.
|
|
1384
3362
|
*
|
|
@@ -1389,29 +3367,59 @@ export function createCopilotSdkSessionRunner({
|
|
|
1389
3367
|
* session.
|
|
1390
3368
|
*/
|
|
1391
3369
|
async function registerContinuationRow(turn) {
|
|
3370
|
+
// One idempotency key for the whole loop: a retry whose predecessor was
|
|
3371
|
+
// created server-side but whose response was lost must get the SAME row
|
|
3372
|
+
// back, not mint a sibling nobody will ever settle.
|
|
3373
|
+
const operationId = randomUUID();
|
|
3374
|
+
// Abandonment is decided elsewhere (the drive path's deadline) while this
|
|
3375
|
+
// loop is parked on an HTTP await, so the state is RE-checked after every
|
|
3376
|
+
// await — the transport has no abort plumbing, which makes these recheck
|
|
3377
|
+
// points the only cancellation this request has.
|
|
3378
|
+
const signal = turn.registrationAbort?.signal || null;
|
|
3379
|
+
const abandoned = () => turn.discarded || signal?.aborted === true;
|
|
1392
3380
|
let response = null;
|
|
1393
3381
|
for (let attempt = 0; attempt < 3 && !response?.messageId; attempt += 1) {
|
|
1394
|
-
if (
|
|
3382
|
+
if (abandoned()) return;
|
|
1395
3383
|
response = await api('POST', '/api/continuation-turn', {
|
|
1396
3384
|
conversationId: sdkSessionId,
|
|
1397
3385
|
sdkSessionId,
|
|
1398
3386
|
relayMode: turn.message.relayMode,
|
|
1399
3387
|
trigger: CONTINUATION_TRIGGER,
|
|
3388
|
+
operationId,
|
|
1400
3389
|
}).catch((error) => {
|
|
1401
3390
|
dbg('continuation turn registration failed', error?.message || String(error));
|
|
1402
3391
|
return null;
|
|
1403
3392
|
});
|
|
1404
3393
|
if (!response?.messageId) {
|
|
3394
|
+
if (abandoned()) return;
|
|
1405
3395
|
await new Promise((resolve) => { setTimeout(resolve, continuationRetryDelayMs); });
|
|
1406
3396
|
}
|
|
1407
3397
|
}
|
|
1408
3398
|
if (!response?.messageId) {
|
|
1409
|
-
turn
|
|
1410
|
-
|
|
1411
|
-
|
|
3399
|
+
abandonContinuationRegistration(turn, 'no relay message id after 3 attempts');
|
|
3400
|
+
return;
|
|
3401
|
+
}
|
|
3402
|
+
const messageId = String(response.messageId);
|
|
3403
|
+
const attemptId = String(response.attemptId || '') || null;
|
|
3404
|
+
if (abandoned()) {
|
|
3405
|
+
// The drive path gave up on this turn while the request was in flight,
|
|
3406
|
+
// and the server has just created a row nobody will publish into.
|
|
3407
|
+
// Starting controls or flushing output here would resurrect an abandoned
|
|
3408
|
+
// turn; instead the orphan row is settled explicitly — the requeue
|
|
3409
|
+
// route's continuation branch tears a processing continuation down as
|
|
3410
|
+
// `dropped: 'continuation'` (the same teardown the Claude worker uses
|
|
3411
|
+
// for a registration that outlived its hand-off).
|
|
3412
|
+
dbg('late continuation registration; tearing the orphan row down', messageId);
|
|
3413
|
+
await api('POST', '/api/requeue', {
|
|
3414
|
+
messageId,
|
|
3415
|
+
...(attemptId ? { attemptId } : {}),
|
|
3416
|
+
}).catch(() => {});
|
|
1412
3417
|
return;
|
|
1413
3418
|
}
|
|
1414
|
-
turn.message.id =
|
|
3419
|
+
turn.message.id = messageId;
|
|
3420
|
+
// The attempt the row was minted under; every publish echoes it, exactly
|
|
3421
|
+
// as a delivered row echoes the attempt id its delivery carried.
|
|
3422
|
+
turn.message.attemptId = attemptId;
|
|
1415
3423
|
// The route reports which conversation the synthetic row landed on;
|
|
1416
3424
|
// trusting it beats assuming worker session id === conversation id.
|
|
1417
3425
|
const conversationId = String(response.conversationId || '').trim();
|
|
@@ -1419,197 +3427,378 @@ export function createCopilotSdkSessionRunner({
|
|
|
1419
3427
|
// Only now is there a row to abort, so this is where the control poller can
|
|
1420
3428
|
// start.
|
|
1421
3429
|
turn.controlState = controlPoller?.start?.({
|
|
1422
|
-
queueMessageId:
|
|
1423
|
-
onAbortTurn:
|
|
1424
|
-
turn.aborted = true;
|
|
1425
|
-
if (!session) return;
|
|
1426
|
-
await session.abort?.();
|
|
1427
|
-
},
|
|
3430
|
+
queueMessageId: '',
|
|
3431
|
+
onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
|
|
1428
3432
|
}) || null;
|
|
1429
3433
|
await flushBufferedActions(turn);
|
|
1430
3434
|
turn.registered = true;
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
async function awaitContinuationRow(turn, timeoutMs = continuationRegistrationTimeoutMs) {
|
|
1435
|
-
const deadline = Date.now() + timeoutMs;
|
|
1436
|
-
while (!turn.registered && !turn.discarded && Date.now() < deadline) {
|
|
1437
|
-
await new Promise((resolve) => { setTimeout(resolve, 25); });
|
|
1438
|
-
}
|
|
3435
|
+
turn.resolveRowReady?.(true);
|
|
3436
|
+
// The row exists: the delivery hold it imposed ends (canAcceptSteering).
|
|
3437
|
+
syncDeliveryReadiness();
|
|
1439
3438
|
}
|
|
1440
3439
|
|
|
1441
3440
|
/**
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
1445
|
-
* `
|
|
1446
|
-
* a delivered turn: a heartbeat firing inside the publish window with no
|
|
1447
|
-
* active ids would tell the relay this worker owns nothing, and the
|
|
1448
|
-
* still-`processing` synthetic row would be recovered underneath it.
|
|
3441
|
+
* Resolve once the continuation's row exists, its registration gave up, or
|
|
3442
|
+
* the local deadline expires — in which case the turn is ABANDONED, so the
|
|
3443
|
+
* registration cannot later adopt a row into it (it tears the row down
|
|
3444
|
+
* instead; see `registerContinuationRow`'s late-response branch).
|
|
1449
3445
|
*/
|
|
1450
|
-
async function
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
await awaitContinuationRow(turn);
|
|
1463
|
-
// The invariant is the id, not the flag: registration can also give up by
|
|
1464
|
-
// throwing, or by taking longer than `awaitContinuationRow` waits, and
|
|
1465
|
-
// publishing against `messageId: null` would attribute the whole turn to
|
|
1466
|
-
// nothing at all.
|
|
1467
|
-
if (!turn.message.id) {
|
|
1468
|
-
turn.discarded = true;
|
|
1469
|
-
turn.bufferedActions = [];
|
|
1470
|
-
dbg('continuation output dropped (no relay row)');
|
|
1471
|
-
// The steering path is still waiting on any row it handed us, and it
|
|
1472
|
-
// has no row of its own to fall back to.
|
|
1473
|
-
await settleSteeredRows(turn, null, null, classifyCopilotTurnException(
|
|
1474
|
-
failure || new Error('the relay refused a continuation row for this turn'),
|
|
1475
|
-
)).catch(() => {});
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
await flushBufferedActions(turn);
|
|
1479
|
-
if (failure) {
|
|
1480
|
-
const classified = classifyCopilotTurnException(failure);
|
|
1481
|
-
dbg('continuation turn failed', turn.message.id, classified.detail);
|
|
1482
|
-
await publishResponse(turn.message, {
|
|
1483
|
-
text: classified.text,
|
|
1484
|
-
model: null,
|
|
1485
|
-
terminalError: terminalErrorRecord(turn.message, classified),
|
|
1486
|
-
});
|
|
1487
|
-
await settleSteeredRows(turn, null, null, classified).catch(() => {});
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1490
|
-
await finishTurn(turn, '');
|
|
1491
|
-
} catch (error) {
|
|
1492
|
-
dbg('continuation publish failed', error?.message || String(error));
|
|
1493
|
-
} finally {
|
|
1494
|
-
controlPoller?.stop?.(turn.controlState);
|
|
1495
|
-
turn.controlState = null;
|
|
1496
|
-
if (activeTurn === turn) activeTurn = null;
|
|
1497
|
-
touch();
|
|
1498
|
-
// A continuation spends real quota (the live capture burned a premium
|
|
1499
|
-
// request on `read_bash` + the reply), so its numbers ride the same
|
|
1500
|
-
// fire-and-forget ingest as a delivered turn's.
|
|
1501
|
-
postTurnUsage();
|
|
3446
|
+
async function awaitContinuationRow(turn, timeoutMs = continuationRegistrationTimeoutMs) {
|
|
3447
|
+
if (turn.registered || turn.discarded) return;
|
|
3448
|
+
let timer = null;
|
|
3449
|
+
const expired = new Promise((resolve) => {
|
|
3450
|
+
timer = setTimeout(() => resolve('expired'), timeoutMs);
|
|
3451
|
+
timer.unref?.();
|
|
3452
|
+
});
|
|
3453
|
+
// `turn.registration` is the caught chain and never rejects.
|
|
3454
|
+
const outcome = await Promise.race([turn.registration || Promise.resolve(), expired]);
|
|
3455
|
+
clearTimeout(timer);
|
|
3456
|
+
if (outcome === 'expired' && !turn.registered && !turn.message.id) {
|
|
3457
|
+
abandonContinuationRegistration(turn, 'registration outlived the local deadline');
|
|
1502
3458
|
}
|
|
1503
3459
|
}
|
|
1504
3460
|
|
|
1505
|
-
|
|
1506
|
-
* Settle every queue row that was steered into this interaction.
|
|
1507
|
-
*
|
|
1508
|
-
* The runtime answers the original prompt AND every prompt queued behind it
|
|
1509
|
-
* under ONE `session.idle` (live-verified — see the plan doc §5), so no
|
|
1510
|
-
* second turn will ever settle these rows. Each one is answered with the text
|
|
1511
|
-
* of ITS OWN prompt segment, which the normalizer separates using the
|
|
1512
|
-
* `user.message` events that mark where the runtime picked each prompt up.
|
|
1513
|
-
*
|
|
1514
|
-
* A row whose prompt the runtime accepted but never started work on has no
|
|
1515
|
-
* segment. It is NOT requeued: the prompt is still sitting in the runtime's
|
|
1516
|
-
* pending queue and will be answered at the start of the next turn, so
|
|
1517
|
-
* redelivering it would run the same prompt twice. It gets a note instead —
|
|
1518
|
-
* the same trade the Claude worker makes for a handed-off context.
|
|
1519
|
-
*/
|
|
1520
|
-
async function settleSteeredRows(turn, result, responseModel, classified = null) {
|
|
1521
|
-
if (!turn.steeredRows.length) return;
|
|
1522
|
-
for (const steered of turn.steeredRows) {
|
|
1523
|
-
const { message: steeredMessage, segmentIndex } = steered;
|
|
1524
|
-
let text = String(turn.normalizer.segmentText(segmentIndex) || '').trim();
|
|
1525
|
-
if (classified) {
|
|
1526
|
-
text = classified.text;
|
|
1527
|
-
} else if (!text) {
|
|
1528
|
-
text = STEERED_ROW_MERGED_NOTE;
|
|
1529
|
-
}
|
|
1530
|
-
await publishFinalStream(steeredMessage, text);
|
|
1531
|
-
await publishResponse(steeredMessage, {
|
|
1532
|
-
text,
|
|
1533
|
-
model: responseModel,
|
|
1534
|
-
...(classified ? { terminalError: terminalErrorRecord(steeredMessage, classified) } : {}),
|
|
1535
|
-
});
|
|
1536
|
-
steered.settle?.();
|
|
1537
|
-
}
|
|
1538
|
-
turn.steeredRows.length = 0;
|
|
1539
|
-
}
|
|
3461
|
+
// --------------------------------------------------------------- steering --
|
|
1540
3462
|
|
|
1541
3463
|
/**
|
|
1542
|
-
* A delivery arrived while a turn was already running
|
|
3464
|
+
* A delivery arrived while a turn was already running: push it into the
|
|
3465
|
+
* same runtime interaction as a steer.
|
|
1543
3466
|
*
|
|
1544
|
-
*
|
|
1545
|
-
*
|
|
1546
|
-
*
|
|
1547
|
-
*
|
|
1548
|
-
*
|
|
1549
|
-
* `mode: "enqueue"` can do — it cannot interrupt an in-flight model call),
|
|
1550
|
-
* and the row is adopted so the interaction's terminator settles it too.
|
|
3467
|
+
* `mode: "immediate"` makes it a real steer — injected at the next tool
|
|
3468
|
+
* boundary (`delivery:"steering"`, folded into the running reply), or run
|
|
3469
|
+
* as its own turn right after when the model is mid-stream
|
|
3470
|
+
* (`delivery:"queued"`). Either way the row is owned by this turn and
|
|
3471
|
+
* settled by it (see adoptSegment / finishTurn); nothing else will.
|
|
1551
3472
|
*
|
|
1552
|
-
* The
|
|
1553
|
-
* leave a row nobody owns.
|
|
3473
|
+
* The entry is registered BEFORE the send so a failure between the two
|
|
3474
|
+
* cannot leave a row nobody owns.
|
|
1554
3475
|
*/
|
|
1555
3476
|
async function steerIntoActiveTurn(turn, message) {
|
|
1556
3477
|
const { prompt: body, attachments } = buildMessageOptionsImpl(message);
|
|
1557
3478
|
// A real relay round trip on the first turn of a mode — long enough for the
|
|
1558
3479
|
// turn to finish underneath us.
|
|
1559
|
-
const
|
|
1560
|
-
|
|
3480
|
+
const context = await buildRelayContextPrefix(message).catch(() => null);
|
|
3481
|
+
// The hidden steer note (shared/steer-note.mjs): a steer that lands before
|
|
3482
|
+
// the model's first output otherwise tempts it to answer only the newest
|
|
3483
|
+
// message and drop the request it was working on (burn-in 2026-09-25).
|
|
3484
|
+
// Not in a continuation: there the running work is the runtime's own
|
|
3485
|
+
// follow-up, not a request of the user's to keep going with.
|
|
3486
|
+
const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' ? body : withSteerNote(body));
|
|
1561
3487
|
// Re-checked AFTER the awaits and before anything is sent or registered.
|
|
1562
|
-
//
|
|
1563
|
-
//
|
|
1564
|
-
// wedging the single-flight delivery socket. Nothing has been sent yet, so
|
|
1565
|
-
// handing the row back to the normal path is free.
|
|
3488
|
+
// Nothing has been sent yet, so handing the row back to the normal path is
|
|
3489
|
+
// free.
|
|
1566
3490
|
if (turn.settled) {
|
|
1567
3491
|
dbg('turn settled while steering was preparing; running it as a fresh turn', message.id);
|
|
1568
3492
|
return NOT_STEERED;
|
|
1569
3493
|
}
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
//
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
turn.promptsSent += 1;
|
|
1577
|
-
const steered = { message, segmentIndex };
|
|
1578
|
-
steered.done = new Promise((resolve) => { steered.settle = resolve; });
|
|
1579
|
-
turn.steeredRows.push(steered);
|
|
1580
|
-
dbg('steering a mid-turn delivery into the running turn', message.id, `segment=${segmentIndex}`);
|
|
3494
|
+
const entry = createEntry(turn, message, 'steer');
|
|
3495
|
+
dbg('steering a mid-turn delivery into the running turn', message.id);
|
|
3496
|
+
let runtimeId;
|
|
3497
|
+
// Counted until send() names the message (see `steerSendsInFlight`).
|
|
3498
|
+
steerSendsInFlight += 1;
|
|
3499
|
+
entry.sendSeq = ++steerSendSeq;
|
|
1581
3500
|
try {
|
|
1582
|
-
await session.send({
|
|
3501
|
+
runtimeId = await session.send({
|
|
1583
3502
|
prompt,
|
|
1584
3503
|
...(attachments?.length ? { attachments } : {}),
|
|
1585
|
-
mode: '
|
|
3504
|
+
mode: 'immediate',
|
|
1586
3505
|
agentMode: copilotAgentModeForRelayMode(message?.relayMode || 'agent'),
|
|
1587
3506
|
});
|
|
1588
3507
|
} catch (error) {
|
|
1589
3508
|
// The prompt never reached the runtime, so nothing will answer it and the
|
|
1590
3509
|
// row is safe to requeue — unlike an accepted one.
|
|
1591
|
-
turn.
|
|
3510
|
+
turn.entries = turn.entries.filter((other) => other !== entry);
|
|
3511
|
+
entry.settled = true;
|
|
3512
|
+
entry.released = true;
|
|
3513
|
+
entry.resolveDone(true);
|
|
3514
|
+
steerSendsInFlight = Math.max(0, steerSendsInFlight - 1);
|
|
1592
3515
|
dbg('steering send failed, requeuing the row', message.id, error?.message || String(error));
|
|
1593
|
-
await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
|
|
3516
|
+
await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
|
|
1594
3517
|
return true;
|
|
1595
3518
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
3519
|
+
assignRuntimeId(turn, entry, runtimeId);
|
|
3520
|
+
steerSendsInFlight = Math.max(0, steerSendsInFlight - 1);
|
|
3521
|
+
// Same commit-after-send rule as `runTurn` (audit #32): a steered prompt
|
|
3522
|
+
// whose send failed never delivered its guidance, so the retry must
|
|
3523
|
+
// include it again.
|
|
3524
|
+
context?.commit();
|
|
3525
|
+
scheduleQueueLaneRefresh();
|
|
3526
|
+
// Resolves when the interaction settles this row.
|
|
3527
|
+
await awaitEntry(turn, entry);
|
|
3528
|
+
return true;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
// ------------------------------------------------------------------ gate --
|
|
3532
|
+
|
|
3533
|
+
/**
|
|
3534
|
+
* Whether a message delivered now would steer into the live turn. False
|
|
3535
|
+
* while: no session / no live turn; the turn-opening prompt has not been
|
|
3536
|
+
* sent yet, or a continuation's row does not exist yet (the delivery hold —
|
|
3537
|
+
* a second send before the first would invert the runs, and a steer's run
|
|
3538
|
+
* would settle the continuation's reply against no row while its buffered
|
|
3539
|
+
* output flushed into the steered one); a question card, ask-mode approval
|
|
3540
|
+
* or elicitation is open (the relay must not push past a card); a
|
|
3541
|
+
* compaction is running.
|
|
3542
|
+
*/
|
|
3543
|
+
function canAcceptSteering() {
|
|
3544
|
+
if (disposed || !session) return false;
|
|
3545
|
+
const turn = activeTurn;
|
|
3546
|
+
if (!turn || turn.settled || turn.aborted) return false;
|
|
3547
|
+
if (!turn.primarySent || awaitingRow(turn)) return false;
|
|
3548
|
+
if (pendingHumanRequests > 0) return false;
|
|
3549
|
+
if (isCompacting()) return false;
|
|
3550
|
+
return true;
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
/** A continuation whose synthetic row is still being minted (and not given up on). */
|
|
3554
|
+
function awaitingRow(turn) {
|
|
3555
|
+
return turn.kind === 'continuation' && !turn.registered && !turn.discarded;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
/**
|
|
3559
|
+
* The one steering gate. A delivery arriving now is held — handed back to
|
|
3560
|
+
* the queue instead of pushed — when a turn is live (or a human request /
|
|
3561
|
+
* compaction is open) and it cannot accept a steer. The socket link consults
|
|
3562
|
+
* this for its readiness too, idle or mid-delivery.
|
|
3563
|
+
*/
|
|
3564
|
+
function isDeliveryHeld() {
|
|
3565
|
+
if (disposed) return false;
|
|
3566
|
+
const turn = activeTurn;
|
|
3567
|
+
const live = Boolean(turn && !turn.settled);
|
|
3568
|
+
if (!live && pendingHumanRequests === 0 && !isCompacting()) return false;
|
|
3569
|
+
return !canAcceptSteering();
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
function syncDeliveryReadiness() {
|
|
3573
|
+
const ready = !isDeliveryHeld();
|
|
3574
|
+
if (ready === lastReportedDeliveryReady) return;
|
|
3575
|
+
lastReportedDeliveryReady = ready;
|
|
3576
|
+
try {
|
|
3577
|
+
onDeliveryReadinessChange(ready);
|
|
3578
|
+
} catch (error) {
|
|
3579
|
+
dbg('delivery readiness listener failed', error?.message || String(error));
|
|
3580
|
+
}
|
|
3581
|
+
}
|
|
3582
|
+
|
|
3583
|
+
/**
|
|
3584
|
+
* Give a held delivery back to the queue with no retry penalty. The relay
|
|
3585
|
+
* re-delivers it once this worker signals ready again, i.e. when the hold
|
|
3586
|
+
* ends — the message then steers into the resumed turn. Pushing it into the
|
|
3587
|
+
* hold instead would bypass the question card or land inside a compaction.
|
|
3588
|
+
*/
|
|
3589
|
+
async function handBackHeldDelivery(message) {
|
|
3590
|
+
dbg('steering held; handing the delivery back to the queue', message.id || '(no id)');
|
|
3591
|
+
if (!message.id) return false;
|
|
3592
|
+
await api('POST', '/api/requeue', {
|
|
3593
|
+
messageId: message.id,
|
|
3594
|
+
class: 'steering-held',
|
|
3595
|
+
...attemptFields(message),
|
|
3596
|
+
}).catch((error) => {
|
|
3597
|
+
if (isStaleAttemptError(error)) {
|
|
3598
|
+
dbg('steering-held hand-back refused as stale_attempt', message.id);
|
|
3599
|
+
return;
|
|
3600
|
+
}
|
|
3601
|
+
dbg('steering-held hand-back failed', message.id, error?.message || String(error));
|
|
3602
|
+
});
|
|
3603
|
+
return false;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
3606
|
+
function* ownedEntries() {
|
|
3607
|
+
if (activeTurn) yield* activeTurn.entries;
|
|
3608
|
+
for (const turn of publishingTurns) yield* turn.entries;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
/**
|
|
3612
|
+
* Pushed rows the client may still cancel: unconsumed and recallable — in
|
|
3613
|
+
* practice the newest waiting steer (immediate sends wait in the runtime's
|
|
3614
|
+
* id-less steering lane), or any row that sits in the queued lane.
|
|
3615
|
+
*/
|
|
3616
|
+
function cancellableIds() {
|
|
3617
|
+
const ids = [];
|
|
3618
|
+
for (const entry of unconsumedSteers()) {
|
|
3619
|
+
if (!recallMethodFor(entry)) continue;
|
|
3620
|
+
const id = String(entry.message?.id || '').trim();
|
|
3621
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
3622
|
+
}
|
|
3623
|
+
return ids;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
/**
|
|
3627
|
+
* Composer-facing steering snapshot, published on the heartbeat: whether a
|
|
3628
|
+
* turn is live, whether a message typed now would steer into it, and — when
|
|
3629
|
+
* it would not — why. `supported` is how the client learns this Copilot
|
|
3630
|
+
* conversation steers at all (its provider type alone cannot tell an SDK
|
|
3631
|
+
* worker from the extension engine); `cancellableIds` are the pushed rows
|
|
3632
|
+
* that may still be pulled back out of the runtime (decision 8).
|
|
3633
|
+
*/
|
|
3634
|
+
function steeringState() {
|
|
3635
|
+
const turn = activeTurn;
|
|
3636
|
+
const turnActive = Boolean(turn && !turn.settled);
|
|
3637
|
+
const canSteer = canAcceptSteering();
|
|
3638
|
+
let holdReason = null;
|
|
3639
|
+
if (turnActive && !canSteer) {
|
|
3640
|
+
if (pendingHumanRequests > 0) holdReason = 'question';
|
|
3641
|
+
else if (isCompacting()) holdReason = 'compaction';
|
|
3642
|
+
else if (!turn.primarySent || awaitingRow(turn)) holdReason = 'delivery';
|
|
3643
|
+
else holdReason = 'other';
|
|
3644
|
+
}
|
|
3645
|
+
const messageId = String(turn?.currentOwner?.message?.id || turn?.message?.id || '').trim() || null;
|
|
3646
|
+
return { turnActive, canSteer, holdReason, messageId, supported: true, cancellableIds: cancellableIds() };
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/**
|
|
3650
|
+
* Re-read the queue, and — only if the prompt is still recallable at that
|
|
3651
|
+
* instant — issue its remove request in the same tick as the check, so no
|
|
3652
|
+
* steer send can be issued in between (JSON-RPC keeps request order).
|
|
3653
|
+
*/
|
|
3654
|
+
async function recallPushedPrompt(entry, id) {
|
|
3655
|
+
// Watched from before the read, so a fold that lands during the read or
|
|
3656
|
+
// the remove is seen however far behind the dispatch chain runs.
|
|
3657
|
+
const watch = { taken: false };
|
|
3658
|
+
recallWatches.set(entry.runtimeId, watch);
|
|
3659
|
+
try {
|
|
3660
|
+
// Only the READ is bounded: a runtime that will not answer it gets no
|
|
3661
|
+
// remove at all. Once a remove is sent it is seen through to its answer —
|
|
3662
|
+
// giving up on it could leave the prompt removed and the row uncancelled.
|
|
3663
|
+
const fresh = await withTimeout(refreshQueueLane().then(() => true), UNSTEER_TIMEOUT_MS, false);
|
|
3664
|
+
if (!fresh) {
|
|
3665
|
+
dbg('un-steer refused: the runtime did not answer the queue read in time', id);
|
|
3666
|
+
return false;
|
|
3667
|
+
}
|
|
3668
|
+
const target = session;
|
|
3669
|
+
const method = watch.taken ? null : recallMethodFor(entry);
|
|
3670
|
+
if (!target || !method) {
|
|
3671
|
+
dbg('un-steer refused: the runtime no longer holds this prompt as recallable', id);
|
|
3672
|
+
return false;
|
|
3673
|
+
}
|
|
3674
|
+
const request = method.kind === 'queued'
|
|
3675
|
+
? target.rpc.queue.removeAt({ id: method.itemId })
|
|
3676
|
+
: target.rpc.queue.removeMostRecent();
|
|
3677
|
+
const removed = (await request)?.removed === true;
|
|
3678
|
+
if (removed && (watch.taken || entry.consumed || entry.settled)) {
|
|
3679
|
+
// The runtime took this prompt before it processed the remove, so
|
|
3680
|
+
// what was removed is some other waiting message — another client's,
|
|
3681
|
+
// never a runtime notification (see `recallWatches`). It cannot be put
|
|
3682
|
+
// back; this row settles through its normal path.
|
|
3683
|
+
dbg('UN-STEER MISMATCH: the runtime took the prompt before the remove; the remove took another message', id, method.kind);
|
|
3684
|
+
return false;
|
|
3685
|
+
}
|
|
3686
|
+
return removed;
|
|
3687
|
+
} finally {
|
|
3688
|
+
if (recallWatches.get(entry.runtimeId) === watch) recallWatches.delete(entry.runtimeId);
|
|
3689
|
+
}
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
function withTimeout(promise, ms, fallback) {
|
|
3693
|
+
let timer = null;
|
|
3694
|
+
return Promise.race([
|
|
3695
|
+
promise,
|
|
3696
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); timer.unref?.(); }),
|
|
3697
|
+
]).finally(() => clearTimeout(timer));
|
|
3698
|
+
}
|
|
3699
|
+
|
|
3700
|
+
/**
|
|
3701
|
+
* Un-steer (decision 8, narrowed 2026-09-25 after the live check): pull a
|
|
3702
|
+
* pushed-but-unconsumed row back out of the runtime and cancel it. Immediate
|
|
3703
|
+
* sends wait in the runtime's id-less steering lane, so only the NEWEST
|
|
3704
|
+
* waiting one is recallable (`queue.removeMostRecent`); a row in the queued
|
|
3705
|
+
* lane goes by its stable id. A prompt the runtime already took cannot be
|
|
3706
|
+
* recalled; the control is then a no-op and the row settles normally.
|
|
3707
|
+
*/
|
|
3708
|
+
async function cancelPushedMessage(messageId) {
|
|
3709
|
+
const id = String(messageId || '').trim();
|
|
3710
|
+
if (!id) return false;
|
|
3711
|
+
let entry = null;
|
|
3712
|
+
for (const candidate of ownedEntries()) {
|
|
3713
|
+
if (String(candidate.message?.id || '') === id) { entry = candidate; break; }
|
|
3714
|
+
}
|
|
3715
|
+
if (!entry || entry.consumed || entry.settled || !entry.runtimeId) {
|
|
3716
|
+
dbg('un-steer refused: row not cancellable', id, entry ? `consumed=${entry.consumed} settled=${entry.settled}` : 'unknown row');
|
|
3717
|
+
return false;
|
|
3718
|
+
}
|
|
3719
|
+
if (!session || !sessionRpc.queue) return false;
|
|
3720
|
+
// One un-steer at a time; each re-reads the queue (that read is bounded —
|
|
3721
|
+
// see recallPushedPrompt). Steer sends never wait on this chain.
|
|
3722
|
+
const run = unsteerChain.then(() => recallPushedPrompt(entry, id));
|
|
3723
|
+
unsteerChain = run.catch(() => false);
|
|
3724
|
+
const removed = await run.catch((error) => {
|
|
3725
|
+
dbg('un-steer failed', id, error?.message || String(error));
|
|
3726
|
+
return false;
|
|
3727
|
+
});
|
|
3728
|
+
if (!removed) return false;
|
|
3729
|
+
// Settled meanwhile by its turn's end, or taken since the answer (the
|
|
3730
|
+
// remove then took another message): it keeps its normal outcome.
|
|
3731
|
+
if (entry.consumed || entry.settled) {
|
|
3732
|
+
dbg('un-steer raced the runtime taking the prompt or the row settling; leaving it to settle normally', id);
|
|
3733
|
+
return false;
|
|
3734
|
+
}
|
|
3735
|
+
entry.cancelled = true;
|
|
3736
|
+
entry.settled = true;
|
|
3737
|
+
while (recalledRuntimeIds.size >= MAX_RECALLED_RUNTIME_IDS) recalledRuntimeIds.delete(recalledRuntimeIds.keys().next().value);
|
|
3738
|
+
recalledRuntimeIds.set(entry.runtimeId, id);
|
|
3739
|
+
dbg('un-steered a pushed prompt', id);
|
|
3740
|
+
// The prompt is gone from the runtime; the row must end cancelled and
|
|
3741
|
+
// never be re-delivered. It stays owned (settling, with the terminal
|
|
3742
|
+
// wording the crash guard would send) until the relay acknowledges the
|
|
3743
|
+
// cancel; if the relay never does, the heartbeat hands it over to fail.
|
|
3744
|
+
claimSettling(entry.message, 'cancelled');
|
|
3745
|
+
const cancelBody = {
|
|
3746
|
+
conversationId: entry.message.conversationId,
|
|
3747
|
+
messageId: id,
|
|
3748
|
+
...attemptFields(entry.message),
|
|
3749
|
+
};
|
|
3750
|
+
let acknowledged = false;
|
|
3751
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
3752
|
+
acknowledged = await api('POST', '/api/queue-cancelled', cancelBody).then(() => true, (error) => {
|
|
3753
|
+
dbg('queue-cancelled publish failed', id, error?.message || String(error));
|
|
3754
|
+
// The row already left `processing` (answered, recovered, or already
|
|
3755
|
+
// cancelled): nothing left for this worker to do.
|
|
3756
|
+
return Number(error?.status) === 404 || Number(error?.status) === 409;
|
|
3757
|
+
});
|
|
3758
|
+
if (acknowledged || attempt >= settleRetryDelaysMs.length) break;
|
|
3759
|
+
await sleep(settleRetryDelaysMs[attempt]);
|
|
3760
|
+
}
|
|
3761
|
+
if (!acknowledged) {
|
|
3762
|
+
dbg('queue-cancelled could not be posted; handing the row to the heartbeat', id);
|
|
3763
|
+
settleFailedTerminals.set(id, buildSteerSettleFailure(entry.message, { variant: 'cancelled', agentLabel: SETTLE_AGENT_LABEL }));
|
|
3764
|
+
}
|
|
3765
|
+
releaseSettling(id);
|
|
3766
|
+
entry.released = true;
|
|
3767
|
+
entry.resolveDone(true);
|
|
3768
|
+
const turn = entry.turn;
|
|
3769
|
+
if (turn) {
|
|
3770
|
+
turn.entries = turn.entries.filter((other) => other !== entry);
|
|
3771
|
+
turn.pushed.delete(entry.runtimeId);
|
|
3772
|
+
}
|
|
3773
|
+
scheduleQueueLaneRefresh();
|
|
1598
3774
|
return true;
|
|
1599
3775
|
}
|
|
1600
3776
|
|
|
1601
3777
|
async function handlePendingPayload(pending) {
|
|
1602
3778
|
const message = pending?.message || null;
|
|
1603
3779
|
if (!message) return false;
|
|
3780
|
+
// Held (a card is open, a compaction is running, the turn-opening prompt
|
|
3781
|
+
// is not sent yet): hand the row back with no retry penalty; the relay
|
|
3782
|
+
// re-delivers it when the hold ends. An older relay (the worker updated on
|
|
3783
|
+
// disk before the relay restarted) treats the hand-back as a failed
|
|
3784
|
+
// delivery — retry, backoff, worker marked errored — so against one the
|
|
3785
|
+
// message is pushed the legacy way.
|
|
3786
|
+
if (isDeliveryHeld() && canHandBackHeldDelivery() !== false) return handBackHeldDelivery(message);
|
|
3787
|
+
// Legacy path only: never send a second prompt before the turn-opening one
|
|
3788
|
+
// (it would invert the runs), and never start a second turn beside one
|
|
3789
|
+
// that is still connecting.
|
|
3790
|
+
if (activeTurn && !activeTurn.settled && !activeTurn.primarySent) {
|
|
3791
|
+
await activeTurn.primaryReady;
|
|
3792
|
+
}
|
|
3793
|
+
// Nor steer into a continuation before its row exists (canAcceptSteering);
|
|
3794
|
+
// bounded like the registration itself, which can hang on a dead relay.
|
|
3795
|
+
if (activeTurn && !activeTurn.settled && awaitingRow(activeTurn)) {
|
|
3796
|
+
await withTimeout(activeTurn.rowReady, continuationRegistrationTimeoutMs, false);
|
|
3797
|
+
}
|
|
1604
3798
|
// A delivery that lands while a turn is running is steered into it rather
|
|
1605
3799
|
// than starting a second one: the runtime has a single conversation and a
|
|
1606
3800
|
// concurrent `send` would interleave into the same interaction anyway —
|
|
1607
3801
|
// this way the row is owned and settled instead of orphaned.
|
|
1608
|
-
//
|
|
1609
|
-
// Deliberately OUTSIDE the try/finally below: that `finally` clears
|
|
1610
|
-
// `activeTurn`, and a steered call returns while the turn it was steered
|
|
1611
|
-
// into is still publishing. Clearing there would tell the heartbeat this
|
|
1612
|
-
// worker owns nothing and the relay would recover the live row.
|
|
1613
3802
|
if (activeTurn && !activeTurn.settled && session) {
|
|
1614
3803
|
const turn = activeTurn;
|
|
1615
3804
|
try {
|
|
@@ -1626,74 +3815,68 @@ export function createCopilotSdkSessionRunner({
|
|
|
1626
3815
|
return true;
|
|
1627
3816
|
}
|
|
1628
3817
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
} catch (error) {
|
|
1632
|
-
const classified = classifyCopilotTurnException(error);
|
|
1633
|
-
dbg('copilot turn failed', message.id, classified.detail);
|
|
1634
|
-
// A failure that killed the session (or came from starting it) leaves a
|
|
1635
|
-
// handle nothing else can use; drop it so the next delivery rebuilds and
|
|
1636
|
-
// resumes rather than sending into a dead runtime.
|
|
1637
|
-
await stopRuntime('turn-failure').catch(() => {});
|
|
1638
|
-
await publishResponse(message, {
|
|
1639
|
-
text: classified.text,
|
|
1640
|
-
model: null,
|
|
1641
|
-
terminalError: terminalErrorRecord(message, classified),
|
|
1642
|
-
});
|
|
1643
|
-
// Rows steered into the turn that just threw are owed a response too —
|
|
1644
|
-
// and, more urgently, `steerIntoActiveTurn` is still awaiting their
|
|
1645
|
-
// settle. Skipping this would wedge that caller (and its queue row)
|
|
1646
|
-
// forever behind a heartbeat that keeps renewing the lease.
|
|
1647
|
-
if (activeTurn) {
|
|
1648
|
-
await settleSteeredRows(activeTurn, null, null, classified).catch(() => {});
|
|
1649
|
-
await closeStraySubagentRuns(activeTurn).catch(() => {});
|
|
1650
|
-
}
|
|
1651
|
-
return true;
|
|
1652
|
-
} finally {
|
|
1653
|
-
// Cleared only once every publish for this row has landed. A heartbeat
|
|
1654
|
-
// that fired during the publish window with no active ids would tell the
|
|
1655
|
-
// relay this worker owns nothing, and the still-processing row would be
|
|
1656
|
-
// recovered (`owner-heartbeat-idle`) and re-delivered — a duplicate
|
|
1657
|
-
// execution racing the response that was already on its way.
|
|
1658
|
-
activeTurn = null;
|
|
1659
|
-
touch();
|
|
1660
|
-
// Every path through the turn — published, failed, aborted, threw — has
|
|
1661
|
-
// finished by here, which is the only safe place for the ingest: it is
|
|
1662
|
-
// advisory, it is not awaited, and it must never be able to delay a reply
|
|
1663
|
-
// that is already written.
|
|
1664
|
-
postTurnUsage();
|
|
1665
|
-
}
|
|
3818
|
+
const turn = createTurn(message);
|
|
3819
|
+
return runTurn(turn);
|
|
1666
3820
|
}
|
|
1667
3821
|
|
|
1668
3822
|
// --------------------------------------------------------------- teardown --
|
|
1669
3823
|
|
|
1670
3824
|
function getActiveQueueMessageId() {
|
|
1671
|
-
|
|
3825
|
+
const live = activeTurn?.currentOwner;
|
|
3826
|
+
if (live && !live.released && live.message?.id) return String(live.message.id);
|
|
3827
|
+
for (const entry of ownedEntries()) {
|
|
3828
|
+
const id = String(entry.message?.id || '');
|
|
3829
|
+
if (id && !entry.released) return id;
|
|
3830
|
+
}
|
|
3831
|
+
return '';
|
|
1672
3832
|
}
|
|
1673
3833
|
|
|
1674
3834
|
/**
|
|
1675
|
-
* Every row this worker owns — the turn's
|
|
3835
|
+
* Every row this worker owns — the running turn's rows and the same for
|
|
3836
|
+
* every settled turn still publishing — as `{ id, attemptId }` entries; a
|
|
3837
|
+
* consumed row whose settle marker is still being saved carries the
|
|
3838
|
+
* terminal failure the crash guard must send instead of a requeue, because
|
|
3839
|
+
* its prompt must never run twice. A row missing from this list would be
|
|
3840
|
+
* recovered mid-flight and re-delivered while the runtime was still
|
|
3841
|
+
* answering it.
|
|
1676
3842
|
*
|
|
1677
3843
|
* Both the heartbeat (lease renewal) and the crash guard (requeue-on-exit)
|
|
1678
|
-
* read this
|
|
1679
|
-
*
|
|
1680
|
-
*
|
|
3844
|
+
* read this: the crash guard takes the entries whole so its requeues stay
|
|
3845
|
+
* fenced to this attempt, while the worker's heartbeat call site unwraps the
|
|
3846
|
+
* ids (the claim payload is id-only).
|
|
1681
3847
|
*/
|
|
1682
3848
|
function getActiveQueueMessageIds() {
|
|
1683
|
-
|
|
1684
|
-
const
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
const
|
|
1689
|
-
if (
|
|
3849
|
+
const entries = [];
|
|
3850
|
+
const byId = new Map();
|
|
3851
|
+
const push = (message, terminalError = null) => {
|
|
3852
|
+
const id = String(message?.id || '').trim();
|
|
3853
|
+
if (!id) return;
|
|
3854
|
+
const existing = byId.get(id);
|
|
3855
|
+
if (existing) {
|
|
3856
|
+
if (terminalError && !existing.terminalError) existing.terminalError = terminalError;
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
const entry = { id, attemptId: message?.attemptId || null, ...(terminalError ? { terminalError } : {}) };
|
|
3860
|
+
byId.set(id, entry);
|
|
3861
|
+
entries.push(entry);
|
|
3862
|
+
};
|
|
3863
|
+
for (const entry of ownedEntries()) {
|
|
3864
|
+
if (entry.released) continue;
|
|
3865
|
+
push(entry.message);
|
|
1690
3866
|
}
|
|
1691
|
-
|
|
3867
|
+
for (const { message, variant } of settlingMessages.values()) {
|
|
3868
|
+
push(message, buildSteerSettleFailure(message, { variant, agentLabel: SETTLE_AGENT_LABEL }));
|
|
3869
|
+
}
|
|
3870
|
+
return entries;
|
|
1692
3871
|
}
|
|
1693
3872
|
|
|
1694
3873
|
async function dispose() {
|
|
1695
3874
|
disposed = true;
|
|
1696
3875
|
stopLifecycleTimer();
|
|
3876
|
+
if (queueLaneTimer) {
|
|
3877
|
+
clearTimeout(queueLaneTimer);
|
|
3878
|
+
queueLaneTimer = null;
|
|
3879
|
+
}
|
|
1697
3880
|
// A question card left `pending` would sit in the UI inviting an answer
|
|
1698
3881
|
// that nothing is left to read. Time them out before the socket goes.
|
|
1699
3882
|
await questionBridge.cancelPendingQuestions?.().catch?.(() => {});
|
|
@@ -1704,7 +3887,21 @@ export function createCopilotSdkSessionRunner({
|
|
|
1704
3887
|
handlePendingPayload,
|
|
1705
3888
|
getActiveQueueMessageId,
|
|
1706
3889
|
getActiveQueueMessageIds,
|
|
1707
|
-
|
|
3890
|
+
// The delivery gate (the socket link's probes) and the composer snapshot
|
|
3891
|
+
// (the heartbeat's).
|
|
3892
|
+
canAcceptSteering,
|
|
3893
|
+
isDeliveryHeld,
|
|
3894
|
+
steeringState,
|
|
3895
|
+
getSettleFailed,
|
|
3896
|
+
acknowledgeSettleFailed,
|
|
3897
|
+
cancelPushedMessage,
|
|
3898
|
+
// The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
|
|
3899
|
+
// subagent lane's Stop tries — the runtime tracks sync agents in the same
|
|
3900
|
+
// registry and refuses ids it does not know.
|
|
3901
|
+
stopBackgroundTask,
|
|
3902
|
+
// "Active" spans both ownerships: a settled turn still publishing must
|
|
3903
|
+
// keep the worker's idle/shutdown gates closed just like a running one.
|
|
3904
|
+
isTurnActive: () => !!activeTurn || publishingTurns.size > 0,
|
|
1708
3905
|
dispose,
|
|
1709
3906
|
// The turn's tokens/cost/TTFT, as posted to `/api/copilot-plan-usage`.
|
|
1710
3907
|
getLastTurnUsage: () => lastTurnUsage,
|
|
@@ -1712,17 +3909,46 @@ export function createCopilotSdkSessionRunner({
|
|
|
1712
3909
|
// never awaits this, which is what keeps a slow relay from holding a
|
|
1713
3910
|
// finished reply.
|
|
1714
3911
|
whenUsagePosted: () => usagePostChain,
|
|
3912
|
+
// The in-flight model-catalog snapshot POST — the same kind of seam.
|
|
3913
|
+
whenModelSnapshotPosted: () => modelSnapshotChain,
|
|
3914
|
+
// The in-flight queued-lane read — same kind of seam.
|
|
3915
|
+
whenQueueLaneRefreshed: () => queueLaneRefreshChain,
|
|
3916
|
+
// The in-flight `rpc.tasks.list()` re-read — same kind of seam. A refresh
|
|
3917
|
+
// that is still debounced counts too, so a caller sees the registry read
|
|
3918
|
+
// that the last event scheduled.
|
|
3919
|
+
whenTasksRefreshed: () => (taskRefreshTimer
|
|
3920
|
+
? new Promise((resolve) => { setTimeout(resolve, taskRefreshMs + 5); }).then(() => taskRefreshChain)
|
|
3921
|
+
: taskRefreshChain),
|
|
1715
3922
|
// Test seams / observability.
|
|
1716
3923
|
_getState: () => ({
|
|
1717
3924
|
hasClient: !!client,
|
|
1718
3925
|
hasSession: !!session,
|
|
1719
|
-
appliedModel,
|
|
3926
|
+
appliedModel: appliedModel(),
|
|
3927
|
+
// The confirmed reasoning effort (null = the model's default).
|
|
3928
|
+
appliedEffort: modelSwitch.current().effort,
|
|
1720
3929
|
lastActivityAt,
|
|
1721
3930
|
// 'delivered' | 'continuation' | '' — which kind of turn, if any, owns
|
|
1722
|
-
// the
|
|
3931
|
+
// the runtime-event stream right now.
|
|
1723
3932
|
activeTurnKind: activeTurn?.kind || '',
|
|
3933
|
+
// Settled turns whose relay publishes have not all landed yet.
|
|
3934
|
+
publishingTurnCount: publishingTurns.size,
|
|
1724
3935
|
backgroundShells: backgroundShells.live(),
|
|
3936
|
+
backgroundTasks: sessionRpc.tasks ? registryCards() : shellTrackerCards(),
|
|
1725
3937
|
continuationDueSince,
|
|
3938
|
+
compacting: isCompacting(),
|
|
3939
|
+
pendingHumanRequests,
|
|
3940
|
+
sessionRpc: { ...sessionRpc },
|
|
3941
|
+
activeEntries: activeTurn
|
|
3942
|
+
? activeTurn.entries.map((entry) => ({
|
|
3943
|
+
id: entry.message?.id || null,
|
|
3944
|
+
role: entry.role,
|
|
3945
|
+
runtimeId: entry.runtimeId,
|
|
3946
|
+
consumed: entry.consumed,
|
|
3947
|
+
delivery: entry.delivery,
|
|
3948
|
+
settled: entry.settled,
|
|
3949
|
+
foldedInto: entry.foldedInto?.message?.id || null,
|
|
3950
|
+
}))
|
|
3951
|
+
: [],
|
|
1726
3952
|
}),
|
|
1727
3953
|
_evaluateLifecycle: evaluateLifecycle,
|
|
1728
3954
|
};
|