@oar-sh/oar 0.9.2 → 0.9.4
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 +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +336 -0
- package/README.md +455 -423
- package/bin/oar.js +25 -10
- package/package.json +11 -5
- package/server/README.md +44 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +1073 -69
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +28 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +125 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +55 -14
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1016 -200
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +149 -6
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +12 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -1
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +40 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +463 -17
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +7 -1
- package/server/repositories/message-repository.mjs +68 -18
- package/server/repositories/question-repository.mjs +18 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +13 -32
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +572 -56
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +378 -84
- package/server/server-runtime.mjs +303 -25
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/api-client.mjs +112 -22
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -63,10 +63,12 @@ import { resolveCopilotProviderConfig } from './copilot-byok-provider.mjs';
|
|
|
63
63
|
import {
|
|
64
64
|
createBackgroundShellTracker,
|
|
65
65
|
createReplayGate,
|
|
66
|
+
describeSettledAgent,
|
|
66
67
|
describeSettledShell,
|
|
67
68
|
isContinuationOpeningEvent,
|
|
69
|
+
settledAgentFromNotification,
|
|
68
70
|
} from './copilot-continuation-signals.mjs';
|
|
69
|
-
import { createCopilotEventNormalizer } from './copilot-sdk-event-normalizer.mjs';
|
|
71
|
+
import { createCopilotEventNormalizer, formatToolActivityText } from './copilot-sdk-event-normalizer.mjs';
|
|
70
72
|
import {
|
|
71
73
|
DEFAULT_MODEL_SWITCH_TIMEOUT_MS,
|
|
72
74
|
createCopilotModelSwitcher,
|
|
@@ -74,6 +76,16 @@ import {
|
|
|
74
76
|
normalizeRelayEffort,
|
|
75
77
|
} from './copilot-model-switch.mjs';
|
|
76
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';
|
|
87
|
+
import { buildCopilotRemoteRelayTool } from './copilot-remote-relay-tool.mjs';
|
|
88
|
+
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
77
89
|
import {
|
|
78
90
|
EXIT_PLAN_BOARD_POSTED_FEEDBACK,
|
|
79
91
|
EXIT_PLAN_NO_BOARD_FEEDBACK,
|
|
@@ -89,6 +101,11 @@ import {
|
|
|
89
101
|
} from './copilot-prompt-context.mjs';
|
|
90
102
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
91
103
|
import { buildModelSnapshotFields, extractModelDescriptors } from '../../shared/model-descriptors.mjs';
|
|
104
|
+
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
105
|
+
import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
106
|
+
import { buildSteerSettleFailure } from '../../shared/steer-settle-failure.mjs';
|
|
107
|
+
import { shouldEmitStreamUpdate } from '../../shared/stream-emit-gating.mjs';
|
|
108
|
+
import { withSteerNote } from '../../shared/steer-note.mjs';
|
|
92
109
|
|
|
93
110
|
// How long the runtime may sit with no session activity before the worker
|
|
94
111
|
// closes it. The worker process itself stays up and reconnects lazily on the
|
|
@@ -132,6 +149,45 @@ const DEFAULT_CONTINUATION_RETRY_DELAY_MS = 500;
|
|
|
132
149
|
const CONTINUATION_REGISTRATION_TIMEOUT_MS = 10_000;
|
|
133
150
|
/** Cap on activity lines carried between turns, so a chatty runtime cannot grow them. */
|
|
134
151
|
const MAX_PENDING_ACTIVITIES = 20;
|
|
152
|
+
/**
|
|
153
|
+
* Backstop for the compaction hold: `session.compaction_start` without a
|
|
154
|
+
* matching `_complete` (a runtime that died mid-compaction and was resumed)
|
|
155
|
+
* must not hold steering forever. Sized far above any plausible compaction,
|
|
156
|
+
* like the Claude worker's `compactionStaleMs`.
|
|
157
|
+
*/
|
|
158
|
+
const DEFAULT_COMPACTION_STALE_MS = 10 * 60_000;
|
|
159
|
+
/**
|
|
160
|
+
* Backoff between attempts to save a consumed steer's settle marker (~31 s in
|
|
161
|
+
* all); after the last one the row fails terminally instead. Same ladder as
|
|
162
|
+
* the Claude worker, because it protects the same invariant: a prompt the
|
|
163
|
+
* runtime already consumed is never re-run.
|
|
164
|
+
*/
|
|
165
|
+
const DEFAULT_SETTLE_RETRY_DELAYS_MS = Object.freeze([1_000, 2_000, 4_000, 8_000, 8_000, 8_000]);
|
|
166
|
+
const DEFAULT_MAX_TERMINAL_SETTLE_ATTEMPTS = 5;
|
|
167
|
+
/** Debounce for re-reading the runtime's queued lane after `pending_messages.modified`. */
|
|
168
|
+
const DEFAULT_QUEUE_LANE_REFRESH_MS = 150;
|
|
169
|
+
/**
|
|
170
|
+
* How long a Stop's acknowledgement is held back for the reply of the row it
|
|
171
|
+
* named when that row's early settle is still publishing (see interruptTurn).
|
|
172
|
+
* Bounded: an unresponsive relay must not hold the Stop itself.
|
|
173
|
+
*/
|
|
174
|
+
const STOPPED_ROW_PUBLISH_WAIT_MS = 5_000;
|
|
175
|
+
/**
|
|
176
|
+
* Debounce for re-reading `rpc.tasks.list()` after `session.background_tasks_changed`
|
|
177
|
+
* (which carries no payload and fires ~20 times per shell call).
|
|
178
|
+
*/
|
|
179
|
+
const DEFAULT_TASK_REFRESH_MS = 250;
|
|
180
|
+
/** Trailing throttle for enrichment-only card updates (a live tool call, tokens). */
|
|
181
|
+
const DEFAULT_TASK_PUBLISH_THROTTLE_MS = 2_000;
|
|
182
|
+
/**
|
|
183
|
+
* How long a pushed prompt the runtime had not started when the turn's idle
|
|
184
|
+
* arrived is kept unsettled, waiting for the runtime to open its run. The
|
|
185
|
+
* drain's idle can race a send that resolved a moment earlier; the runtime
|
|
186
|
+
* then starts the prompt right away (`immediate` when idle) and its
|
|
187
|
+
* `user.message` adopts the row as a turn of its own. Same value as the
|
|
188
|
+
* Claude worker's fold grace, for the same reason.
|
|
189
|
+
*/
|
|
190
|
+
const DEFAULT_ORPHAN_GRACE_MS = 2_000;
|
|
135
191
|
|
|
136
192
|
/**
|
|
137
193
|
* Appended to the partial answer when the RUNTIME interrupted the turn on its
|
|
@@ -145,15 +201,27 @@ export const RUNTIME_INTERRUPTED_NOTE =
|
|
|
145
201
|
|
|
146
202
|
/**
|
|
147
203
|
* Published to a steered queue row whose prompt the runtime accepted but never
|
|
148
|
-
* opened work on before the interaction ended. The prompt is still
|
|
149
|
-
* INSIDE the runtime, so it will be answered at the start of the next
|
|
150
|
-
* requeuing the row would run it twice.
|
|
151
|
-
*
|
|
204
|
+
* opened work on before the interaction ended normally. The prompt is still
|
|
205
|
+
* queued INSIDE the runtime, so it will be answered at the start of the next
|
|
206
|
+
* turn — requeuing the row would run it twice. With `mode: "immediate"` the
|
|
207
|
+
* runtime drains every pushed prompt before it idles, so this is a defensive
|
|
208
|
+
* fallback rather than a routine outcome.
|
|
152
209
|
*/
|
|
153
210
|
export const STEERED_ROW_MERGED_NOTE =
|
|
154
211
|
'_(This message was delivered while the previous turn was still running; the reply continues in '
|
|
155
212
|
+ 'the next turn.)_';
|
|
156
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The reply of a continuation row opened only so a background task's question
|
|
216
|
+
* had a row to hang its card off (see `ensureInteractiveTurn`). The runtime
|
|
217
|
+
* produced no root work for it, so nothing else would ever answer the row.
|
|
218
|
+
*/
|
|
219
|
+
export const BACKGROUND_QUESTION_NOTE =
|
|
220
|
+
'System note: a background task asked for input here; it carries on in the background.';
|
|
221
|
+
|
|
222
|
+
/** The provider name in the at-most-once settle failure wording. */
|
|
223
|
+
const SETTLE_AGENT_LABEL = 'Copilot';
|
|
224
|
+
|
|
157
225
|
/**
|
|
158
226
|
* The `trigger` reported to `POST /api/continuation-turn`, matching the value
|
|
159
227
|
* the Claude worker sends so the relay's `CONTINUATION … trigger=` log line and
|
|
@@ -199,6 +267,22 @@ function isStaleAttemptError(error) {
|
|
|
199
267
|
return detail.includes('stale_attempt');
|
|
200
268
|
}
|
|
201
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Whether publishing `action` makes its row worth keeping (`entry.showedWork`).
|
|
272
|
+
* A bookkeeping line only tracks background work the task card already shows,
|
|
273
|
+
* and so does a line carried in from between turns (a shell or agent the card
|
|
274
|
+
* showed finishing) — unless it reports a failure. The card lists running
|
|
275
|
+
* tasks only, so a failed agent's card vanishes exactly like a finished one's,
|
|
276
|
+
* and a `subagent.failed` between turns reaches no row: that line is the
|
|
277
|
+
* failure's only trace. It renders only through its row's response, so
|
|
278
|
+
* dropping the row would drop the failure with it.
|
|
279
|
+
*/
|
|
280
|
+
function showsWork(action) {
|
|
281
|
+
if (action.channel === 'init') return false;
|
|
282
|
+
if (action.carried) return action.reportsFailure === true;
|
|
283
|
+
return !action.bookkeeping;
|
|
284
|
+
}
|
|
285
|
+
|
|
202
286
|
export function createCopilotSdkSessionRunner({
|
|
203
287
|
api,
|
|
204
288
|
sdkSessionId,
|
|
@@ -220,15 +304,16 @@ export function createCopilotSdkSessionRunner({
|
|
|
220
304
|
resolveProviderConfigImpl = resolveCopilotProviderConfig,
|
|
221
305
|
// Threading seam for `MessageOptions.mode` ("enqueue" | "immediate").
|
|
222
306
|
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
|
|
307
|
+
// "immediate", always (fake-provider probe against runtime 1.0.88,
|
|
308
|
+
// 2026-09-25). Mid-turn it is a real steer: injected at the next tool
|
|
309
|
+
// boundary as `user.message{delivery:"steering"}` (or, when the model is
|
|
310
|
+
// mid-stream, moved to the FRONT of the queue and run right after as
|
|
311
|
+
// `delivery:"queued"`). When the main loop is idle both modes start a run at
|
|
312
|
+
// once — except that an "enqueue" message sent while a background agent or
|
|
313
|
+
// an attached shell keeps `session.idle` deferred sits FROZEN until that
|
|
314
|
+
// work ends, while an "immediate" one runs immediately. So immediate is the
|
|
315
|
+
// only mode that never strands a message behind background work.
|
|
316
|
+
resolveSendModeImpl = () => 'immediate',
|
|
232
317
|
// Interactive surfaces. Tests inject a fake bridge; nothing here reaches the
|
|
233
318
|
// relay without one.
|
|
234
319
|
createQuestionBridgeImpl = createCopilotQuestionBridge,
|
|
@@ -259,6 +344,35 @@ export function createCopilotSdkSessionRunner({
|
|
|
259
344
|
// How long a settled continuation waits for its row before giving up on it.
|
|
260
345
|
// Must outlast the registration's own retries; a test shortens it.
|
|
261
346
|
continuationRegistrationTimeoutMs = CONTINUATION_REGISTRATION_TIMEOUT_MS,
|
|
347
|
+
compactionStaleMs = DEFAULT_COMPACTION_STALE_MS,
|
|
348
|
+
settleRetryDelaysMs = DEFAULT_SETTLE_RETRY_DELAYS_MS,
|
|
349
|
+
maxTerminalSettleAttempts = DEFAULT_MAX_TERMINAL_SETTLE_ATTEMPTS,
|
|
350
|
+
queueLaneRefreshMs = DEFAULT_QUEUE_LANE_REFRESH_MS,
|
|
351
|
+
orphanGraceMs = DEFAULT_ORPHAN_GRACE_MS,
|
|
352
|
+
// Register the relay's own `ask_user` (with `multi_select`) over the
|
|
353
|
+
// runtime's built-in one. A runtime that refuses the override falls back
|
|
354
|
+
// to the built-in for the rest of this worker's life.
|
|
355
|
+
relayAskUserTool = true,
|
|
356
|
+
// Whether sessions carry the relay's `remote_relay` tool
|
|
357
|
+
// (shared/remote-relay-tool-core.mjs). Default: ask the relay.
|
|
358
|
+
remoteRelayToolGate = null,
|
|
359
|
+
taskRefreshMs = DEFAULT_TASK_REFRESH_MS,
|
|
360
|
+
taskPublishThrottleMs = DEFAULT_TASK_PUBLISH_THROTTLE_MS,
|
|
361
|
+
// Called with `true`/`false` whenever the runner flips between accepting
|
|
362
|
+
// deliveries and holding them (see isDeliveryHeld). The worker wires it to
|
|
363
|
+
// the socket link: a hold withdraws the relay's readiness, and its end
|
|
364
|
+
// re-arms it at once so a held message steers into the resumed turn.
|
|
365
|
+
onDeliveryReadinessChange = () => {},
|
|
366
|
+
// False while the connected relay does not understand the `steering-held`
|
|
367
|
+
// hand-back (a worker updated on disk before the relay restarted): a held
|
|
368
|
+
// delivery is then pushed the legacy way instead of being handed back into
|
|
369
|
+
// a retry-and-backoff requeue.
|
|
370
|
+
canHandBackHeldDelivery = () => true,
|
|
371
|
+
// Called when the set of recallable (un-steerable) rows changes. The worker
|
|
372
|
+
// wires it to an immediate heartbeat: the client's Cancel appears from the
|
|
373
|
+
// heartbeat snapshot, and a 10 s wait would often outlast the window in
|
|
374
|
+
// which the runtime still holds the message (live check 2026-09-25).
|
|
375
|
+
onCancellableChange = () => {},
|
|
262
376
|
dbg = () => {},
|
|
263
377
|
} = {}) {
|
|
264
378
|
let client = null;
|
|
@@ -303,12 +417,105 @@ export function createCopilotSdkSessionRunner({
|
|
|
303
417
|
let starting = null;
|
|
304
418
|
let disposed = false;
|
|
305
419
|
let detachRuntimeExit = () => {};
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
420
|
+
// A compaction in progress (`session.compaction_start` seen, no `_complete`
|
|
421
|
+
// yet). Holds steering: a message pushed now would land inside the
|
|
422
|
+
// compaction's replay. `compactingSince` backs the stale cap.
|
|
423
|
+
let compactingSince = 0;
|
|
424
|
+
// The last readiness value reported to `onDeliveryReadinessChange`, so the
|
|
425
|
+
// link only hears about flips.
|
|
426
|
+
let lastReportedDeliveryReady = null;
|
|
427
|
+
// Rows whose prompt the runtime consumed and whose settle marker is being
|
|
428
|
+
// saved (id → { message, variant }). Reported by the heartbeat WITH a
|
|
429
|
+
// terminal error until the publish lands, so recovery can never re-run them
|
|
430
|
+
// (see publishSettleMarker).
|
|
431
|
+
const settlingMessages = new Map();
|
|
432
|
+
// Settle markers that could not be posted even terminally; the heartbeat
|
|
433
|
+
// hands them to the relay to fail (id → terminalError).
|
|
434
|
+
const settleFailedTerminals = new Map();
|
|
435
|
+
// Per-row chains of `/api/queue-consumed` POSTs, so a mark and a later
|
|
436
|
+
// un-mark for the same row reach the relay in order.
|
|
437
|
+
const consumedMarkChains = new Map();
|
|
438
|
+
// The runtime's queued lane as last read from `rpc.queue.pendingItems`:
|
|
439
|
+
// runtime message id → stable queue item id. What un-steer needs, and what
|
|
440
|
+
// decides which pushed rows the client may still cancel.
|
|
441
|
+
let queuedLane = new Map();
|
|
442
|
+
// The rest of the last queue snapshot: how many immediate messages wait in
|
|
443
|
+
// the runtime's steering lane (entries not yet folded into the running
|
|
444
|
+
// turn), and how many items the queued lane holds. The steering lane has no
|
|
445
|
+
// ids — only its newest entry can be removed (`queue.removeMostRecent`,
|
|
446
|
+
// LIFO across both lanes; probe 2026-09-25) — so these decide whether our
|
|
447
|
+
// newest steer is still recallable.
|
|
448
|
+
let waitingSteers = 0;
|
|
449
|
+
let queuedItemCount = 0;
|
|
450
|
+
// Guards for the LIFO recall (removeMostRecent takes the runtime's NEWEST
|
|
451
|
+
// waiting message, whoever sent it):
|
|
452
|
+
// - `steerSendsInFlight`: steer sends whose `send()` has not resolved yet.
|
|
453
|
+
// The runtime may already count such a message while this worker cannot
|
|
454
|
+
// yet name it, so no "newest" recall is attempted while any is in flight.
|
|
455
|
+
// - `steerSendSeq`: send order across every owned turn, so "our newest
|
|
456
|
+
// unconsumed steer" is decided by when it was sent, not by which turn
|
|
457
|
+
// object happens to hold it.
|
|
458
|
+
// - `unsteerChain`: un-steers run one at a time (each re-reads the queue).
|
|
459
|
+
// Steer sends never wait on an un-steer; the recall check and its remove
|
|
460
|
+
// request run in the same tick, so no send can be issued between them.
|
|
461
|
+
//
|
|
462
|
+
// What else removeMostRecent can take (live probe, runtime 1.0.85,
|
|
463
|
+
// 2026-09-26): only user-facing sends. The runtime's own notifications
|
|
464
|
+
// (agent_idle / agent_completed / shell_completed) never show in either
|
|
465
|
+
// lane — with one pending and nothing of ours queued the remove answered
|
|
466
|
+
// `removed:false`, and the notification was still delivered after a remove
|
|
467
|
+
// that took our steer. A `source:"system"` send is hidden from pendingItems
|
|
468
|
+
// and skipped (the remove took the OLDER user steer; the system one still
|
|
469
|
+
// ran). An `agent-*`-sourced send is listed and removable, but the
|
|
470
|
+
// runtime's inter-agent prompts never passed through the main lanes. So the
|
|
471
|
+
// read-then-remove window cannot drop a runtime message; what it can still
|
|
472
|
+
// hit is a message another client of the session pushed in that window,
|
|
473
|
+
// which the two guards below detect:
|
|
474
|
+
// - `recallWatches`: runtime id → { taken } for a recall in progress. A
|
|
475
|
+
// root `user.message` naming it sets `taken` from the event callback
|
|
476
|
+
// itself — the SDK dispatches a notification before it resolves a later
|
|
477
|
+
// RPC answer, whereas `entry.consumed` is set on the dispatch chain and
|
|
478
|
+
// can lag behind a slow publish.
|
|
479
|
+
// - `recalledRuntimeIds`: runtime id → row id of every prompt pulled back
|
|
480
|
+
// and cancelled. One must never run; if it does, the remove took
|
|
481
|
+
// another message and the row was cancelled wrongly — logged loudly.
|
|
482
|
+
let steerSendsInFlight = 0;
|
|
483
|
+
let lastCancellableSignature = '';
|
|
484
|
+
let steerSendSeq = 0;
|
|
485
|
+
let unsteerChain = Promise.resolve();
|
|
486
|
+
const recallWatches = new Map();
|
|
487
|
+
const recalledRuntimeIds = new Map();
|
|
488
|
+
const MAX_RECALLED_RUNTIME_IDS = 200;
|
|
489
|
+
/** How long an un-steer may wait on the runtime before it gives up. */
|
|
490
|
+
const UNSTEER_TIMEOUT_MS = 5_000;
|
|
491
|
+
let queueLaneTimer = null;
|
|
492
|
+
let queueLaneRefreshChain = Promise.resolve();
|
|
493
|
+
// Whether the session's `rpc.queue` / `rpc.interruptMainTurn` / `rpc.tasks`
|
|
494
|
+
// are present. Probed per session (the runtime auto-updates under the relay
|
|
495
|
+
// and every one of these is @experimental); a missing surface degrades to
|
|
496
|
+
// the previous behaviour, never to an error.
|
|
497
|
+
let sessionRpc = { queue: false, interruptMainTurn: false, tasks: false };
|
|
498
|
+
// Set once a runtime refused the relay's `ask_user` override: every later
|
|
499
|
+
// session is built without it (the built-in + wording heuristic remain).
|
|
500
|
+
let askUserOverrideRejected = false;
|
|
501
|
+
// The runtime's background task registry as last read from
|
|
502
|
+
// `rpc.tasks.list()` (id → TaskInfo), and what the event stream adds per
|
|
503
|
+
// agent that the registry does not carry: the live tool call, a running
|
|
504
|
+
// token count, the model announced at spawn. Both feed the task panel
|
|
505
|
+
// cards; the registry alone decides liveness (the lifecycle pin).
|
|
506
|
+
let backgroundTasks = new Map();
|
|
507
|
+
const agentDetails = new Map();
|
|
508
|
+
let taskRefreshTimer = null;
|
|
509
|
+
let taskRefreshChain = Promise.resolve();
|
|
510
|
+
let lastTaskRefreshAt = 0;
|
|
511
|
+
let taskPublishTimer = null;
|
|
512
|
+
let lastPublishedTasksSignature = '';
|
|
513
|
+
// Cap expiry is bounded: a task whose cancel the runtime refuses (or that
|
|
514
|
+
// keeps failing) this many times is abandoned — dropped from the live set so
|
|
515
|
+
// it stops pinning the runtime — rather than held forever.
|
|
516
|
+
const taskCancelRefusals = new Map();
|
|
517
|
+
const abandonedTasks = new Set();
|
|
518
|
+
const MAX_TASK_CANCEL_REFUSALS = 3;
|
|
312
519
|
// Blocking handlers currently waiting on a human (`ask_user`, an ask-mode
|
|
313
520
|
// tool approval). The runtime emits NO events while blocked in one, and the
|
|
314
521
|
// question timeout is 8 hours against a 120s stall ceiling — so without this
|
|
@@ -316,6 +523,17 @@ export function createCopilotSdkSessionRunner({
|
|
|
316
523
|
// the human's eventual answer to a runtime whose row is already settled.
|
|
317
524
|
// Same guard the Cursor worker's `hasPendingClientWork` provides.
|
|
318
525
|
let pendingHumanRequests = 0;
|
|
526
|
+
// Running `remote_relay` calls. The runtime is just as silent while one
|
|
527
|
+
// runs (the relay may wait up to 600 s for a remote turn, plus an approval
|
|
528
|
+
// card), so they hold the stall watchdog and the idle shutdown too — but
|
|
529
|
+
// not steering: nobody is answering a card here.
|
|
530
|
+
let pendingRelayToolCalls = 0;
|
|
531
|
+
// `remote_relay` is registered only while this relay has paired remotes.
|
|
532
|
+
// Asked once when the worker starts; a session build waits for that first
|
|
533
|
+
// answer (it is async anyway), and every runtime stop asks again so the
|
|
534
|
+
// next session picks up a remote paired in the meantime.
|
|
535
|
+
const remoteRelayGate = remoteRelayToolGate || createRemoteRelayToolGate({ api });
|
|
536
|
+
void remoteRelayGate.refresh();
|
|
319
537
|
// The `SessionConfig.provider` block this session was BUILT with, or null for
|
|
320
538
|
// a hosted (`github`) session. Set by `buildSessionConfig` rather than by a
|
|
321
539
|
// second throwaway resolve here, so "is this BYOK?" and "what did the runtime
|
|
@@ -334,10 +552,11 @@ export function createCopilotSdkSessionRunner({
|
|
|
334
552
|
// continuation opening, or by the grace expiring on a runtime that decided it
|
|
335
553
|
// had nothing to say.
|
|
336
554
|
let continuationDueSince = 0;
|
|
337
|
-
// Transcript lines produced between turns (a settled shell's
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// makes.
|
|
555
|
+
// Transcript lines produced between turns (a settled shell's or background
|
|
556
|
+
// agent's notification), as `{ text, reportsFailure }`. They belong to the
|
|
557
|
+
// turn they trigger, so they are carried into it rather than dropped — the
|
|
558
|
+
// same trade the Claude worker's `pendingActivities` makes. A line that
|
|
559
|
+
// reports a failure keeps the row it lands on (see `showsWork`).
|
|
341
560
|
let pendingActivities = [];
|
|
342
561
|
// The relay mode of the last delivered turn. A self-initiated turn has no
|
|
343
562
|
// delivery to read a mode off, and it is a continuation OF that turn's work,
|
|
@@ -345,33 +564,73 @@ export function createCopilotSdkSessionRunner({
|
|
|
345
564
|
// plan-board gating behaving the same either side of a background wait.
|
|
346
565
|
let lastRelayMode = 'agent';
|
|
347
566
|
|
|
567
|
+
/**
|
|
568
|
+
* The turn a human request belongs to, opened on demand when none is live.
|
|
569
|
+
*
|
|
570
|
+
* A card needs a `processing` row (`/api/relay-question` 409s otherwise),
|
|
571
|
+
* and a background agent can ask long after the turn that spawned it settled
|
|
572
|
+
* on `assistant.idle`. Nothing else opens a row for that question, so it
|
|
573
|
+
* gets a continuation of its own — the Claude worker opens one before an
|
|
574
|
+
* off-turn `AskUserQuestion` for the same reason. `humanOrigin`: unless root
|
|
575
|
+
* work joins it, it closes itself once its last card is done.
|
|
576
|
+
*/
|
|
577
|
+
function ensureInteractiveTurn() {
|
|
578
|
+
if (activeTurn || !session || disposed) return activeTurn;
|
|
579
|
+
const turn = openContinuationTurn();
|
|
580
|
+
turn.humanOrigin = true;
|
|
581
|
+
return turn;
|
|
582
|
+
}
|
|
583
|
+
|
|
348
584
|
async function whileAwaitingHuman(run) {
|
|
585
|
+
// Captured once: the card belongs to this turn's row even when another
|
|
586
|
+
// turn owns the event stream by the time it is created or answered.
|
|
587
|
+
const turn = ensureInteractiveTurn();
|
|
349
588
|
pendingHumanRequests += 1;
|
|
589
|
+
if (turn) {
|
|
590
|
+
if (turn.humanRequests === 0) turn.humansIdle = new Promise((resolve) => { turn.resolveHumansIdle = resolve; });
|
|
591
|
+
turn.humanRequests += 1;
|
|
592
|
+
turn.askedHuman = true;
|
|
593
|
+
}
|
|
594
|
+
// An open card holds steering: the relay's readiness is withdrawn at once.
|
|
595
|
+
syncDeliveryReadiness();
|
|
350
596
|
try {
|
|
351
|
-
return await run();
|
|
597
|
+
return await run(turn);
|
|
352
598
|
} finally {
|
|
353
599
|
pendingHumanRequests -= 1;
|
|
600
|
+
if (turn) {
|
|
601
|
+
turn.humanRequests -= 1;
|
|
602
|
+
if (turn.humanRequests === 0) {
|
|
603
|
+
turn.resolveHumansIdle();
|
|
604
|
+
// A row opened only for questions: no terminator is coming for it.
|
|
605
|
+
if (turn.humanOrigin && !turn.rootWork && !turn.settled) {
|
|
606
|
+
turn.result = { subtype: 'completed', text: BACKGROUND_QUESTION_NOTE };
|
|
607
|
+
turn.settle();
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
354
611
|
// The clock restarts from the answer, not from before the wait.
|
|
355
612
|
touch();
|
|
356
613
|
activeTurn?.armStall?.();
|
|
614
|
+
// The answer re-arms the relay immediately, so a message queued while
|
|
615
|
+
// the card was open steers into the resumed turn within one round trip.
|
|
616
|
+
syncDeliveryReadiness();
|
|
357
617
|
}
|
|
358
618
|
}
|
|
359
619
|
|
|
360
620
|
/**
|
|
361
|
-
* Hold an interactive callback until
|
|
621
|
+
* Hold an interactive callback until its turn's queue row exists.
|
|
362
622
|
*
|
|
363
623
|
* A continuation becomes the active turn synchronously but gets its row
|
|
364
624
|
* asynchronously, and the runtime can block on `user_input.requested` (or an
|
|
365
625
|
* ask-mode approval) in that gap — the question bridge would then see no
|
|
366
|
-
*
|
|
367
|
-
*
|
|
626
|
+
* row and degrade to an unsupported answer / local rejection for a card
|
|
627
|
+
* that was milliseconds from having a real row id. Bounded by the
|
|
368
628
|
* registration timeout (the registration itself can hang on a dead relay);
|
|
369
629
|
* on expiry — or on a registration that gave up (`rowReady` → false) — the
|
|
370
630
|
* caller proceeds and degrades exactly as before. Delivered turns are
|
|
371
631
|
* registered from birth and skip straight through.
|
|
372
632
|
*/
|
|
373
|
-
async function awaitInteractiveRow() {
|
|
374
|
-
const turn = activeTurn;
|
|
633
|
+
async function awaitInteractiveRow(turn) {
|
|
375
634
|
if (!turn || turn.registered || !turn.rowReady) return;
|
|
376
635
|
let timer = null;
|
|
377
636
|
await Promise.race([
|
|
@@ -537,19 +796,89 @@ export function createCopilotSdkSessionRunner({
|
|
|
537
796
|
}
|
|
538
797
|
|
|
539
798
|
/**
|
|
540
|
-
* Publish an action
|
|
799
|
+
* Publish an action onto the row that owns the runtime's current run, or
|
|
800
|
+
* hold it if the turn has no queue row yet.
|
|
541
801
|
*
|
|
542
802
|
* The single gate every producer goes through. A continuation's row is
|
|
543
803
|
* created asynchronously, and a POST carrying `messageId: null` is not merely
|
|
544
804
|
* useless — the relay's activity/stream routes key on it, so it would be
|
|
545
805
|
* attributed to nothing at all.
|
|
806
|
+
*
|
|
807
|
+
* One turn can answer several rows in sequence (the primary, then a message
|
|
808
|
+
* pushed mid-turn that the runtime ran as its own `delivery:"queued"` run).
|
|
809
|
+
* Root-level traffic — stream, thoughts, activity, subagent lanes — goes to
|
|
810
|
+
* whichever row owns the run in progress (`turn.currentOwner`); the stream
|
|
811
|
+
* text is re-cut to that row's own segments so a second run's prose never
|
|
812
|
+
* appears under the first row.
|
|
546
813
|
*/
|
|
547
814
|
async function emitAction(turn, action) {
|
|
548
815
|
if (!turn.registered) {
|
|
549
816
|
turn.bufferedActions.push(action);
|
|
550
817
|
return;
|
|
551
818
|
}
|
|
552
|
-
await
|
|
819
|
+
await dispatchOwned(turn, action);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function dispatchOwned(turn, action) {
|
|
823
|
+
const owner = turn.currentOwner || turn.primaryEntry;
|
|
824
|
+
if (!owner || owner.settled) {
|
|
825
|
+
// Nothing left to publish into (every row this turn owned has settled);
|
|
826
|
+
// the text still lands in the runtime's own transcript.
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (showsWork(action)) owner.showedWork = true;
|
|
830
|
+
if (action.channel === 'stream' && !action.payload?.subagentRunId) {
|
|
831
|
+
// Re-cut to the owner's own segments, and gated against what THAT row
|
|
832
|
+
// last received (the normalizer gated against the whole interaction).
|
|
833
|
+
const text = textFor(turn, owner);
|
|
834
|
+
if (!shouldEmitStreamUpdate(text, owner.state.lastStreamedText)) return;
|
|
835
|
+
await dispatchAction(owner.message, { channel: 'stream', payload: { ...action.payload, text } }, owner.state);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
await dispatchAction(owner.message, action, owner.state);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Which row a normalizer segment's text belongs to. A segment nobody
|
|
843
|
+
* claimed (text produced before any prompt boundary — the answer to a
|
|
844
|
+
* prompt carried over from a previous interaction, or a turn the runtime
|
|
845
|
+
* answered without echoing a `user.message` at all) belongs to the next
|
|
846
|
+
* claimed segment's row, failing that to the turn's own row.
|
|
847
|
+
*/
|
|
848
|
+
function segmentOwner(turn, index) {
|
|
849
|
+
const owners = turn.segmentOwners;
|
|
850
|
+
if (owners[index]) return owners[index];
|
|
851
|
+
for (let j = index + 1; j < owners.length; j += 1) {
|
|
852
|
+
if (owners[j]) return owners[j];
|
|
853
|
+
}
|
|
854
|
+
return turn.primaryEntry;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function textFor(turn, entry, segmentTexts = null) {
|
|
858
|
+
const count = Math.max(turn.normalizer.promptCount(), turn.segmentOwners.length);
|
|
859
|
+
const parts = [];
|
|
860
|
+
for (let index = 0; index < count; index += 1) {
|
|
861
|
+
if (segmentOwner(turn, index) !== entry) continue;
|
|
862
|
+
const text = String(segmentTexts?.[index] ?? turn.normalizer.segmentText(index) ?? '');
|
|
863
|
+
if (text.trim()) parts.push(text);
|
|
864
|
+
}
|
|
865
|
+
return parts.join('\n\n');
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* The final reply text for `entry`: its segments, with the task-completion
|
|
870
|
+
* summary taking precedence on the last one (the normalizer's
|
|
871
|
+
* `result.segmentTexts` already applies that rule; before this the runner
|
|
872
|
+
* read `segmentText` directly and lost the summary — the "Cha…" fragment
|
|
873
|
+
* case of 2026-09-12). A turn that opened no segment at all falls back to
|
|
874
|
+
* the whole composed text for its own row, which is strictly better than
|
|
875
|
+
* publishing nothing.
|
|
876
|
+
*/
|
|
877
|
+
function finalTextFor(turn, entry, result) {
|
|
878
|
+
const segmentTexts = Array.isArray(result?.segmentTexts) ? result.segmentTexts : null;
|
|
879
|
+
const text = textFor(turn, entry, segmentTexts).trim();
|
|
880
|
+
if (text || entry !== turn.primaryEntry) return text;
|
|
881
|
+
return String(result?.text || entry.state.lastStreamedText || '').trim();
|
|
553
882
|
}
|
|
554
883
|
|
|
555
884
|
async function publishFinalStream(message, text) {
|
|
@@ -563,26 +892,54 @@ export function createCopilotSdkSessionRunner({
|
|
|
563
892
|
}).catch(() => {});
|
|
564
893
|
}
|
|
565
894
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
895
|
+
/**
|
|
896
|
+
* Publish a row's outcome. Returns 'published' | 'stale' | 'failed' |
|
|
897
|
+
* 'requeued' so the settle paths can tell a landed marker from a lost one.
|
|
898
|
+
*
|
|
899
|
+
* `kind` ('folded' | 'stopped') stamps the settle stubs of a steered row the
|
|
900
|
+
* runtime consumed without answering on its own row; `consumedSteerIds`
|
|
901
|
+
* names the steers folded into the turn this response finishes, so the
|
|
902
|
+
* relay marks them consumed in the same fenced write. A response carrying a
|
|
903
|
+
* kind is never requeued on failure (the prompt was consumed — re-running it
|
|
904
|
+
* is the one thing that must not happen); `requeueOnFailure: false` opts an
|
|
905
|
+
* ordinary response out of the requeue too.
|
|
906
|
+
*/
|
|
907
|
+
async function publishResponse(message, {
|
|
908
|
+
text, model, terminalError = null, modelOrigin, kind = null, consumedSteerIds = null,
|
|
909
|
+
requeueOnFailure = true,
|
|
910
|
+
}) {
|
|
911
|
+
const consumed = Array.isArray(consumedSteerIds)
|
|
912
|
+
? consumedSteerIds
|
|
913
|
+
.map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
|
|
914
|
+
.filter((entry) => entry.id)
|
|
915
|
+
: [];
|
|
916
|
+
try {
|
|
917
|
+
await api('POST', '/api/response', {
|
|
918
|
+
messageId: message.id,
|
|
919
|
+
conversationId: message.conversationId,
|
|
920
|
+
text: String(text || ''),
|
|
921
|
+
model: model || null,
|
|
922
|
+
modelOrigin: modelOrigin
|
|
923
|
+
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
924
|
+
...(terminalError ? { terminalError } : {}),
|
|
925
|
+
...(kind ? { kind } : {}),
|
|
926
|
+
...(consumed.length ? { consumedSteerIds: consumed } : {}),
|
|
927
|
+
...attemptFields(message),
|
|
928
|
+
});
|
|
929
|
+
return 'published';
|
|
930
|
+
} catch (error) {
|
|
577
931
|
// A stale_attempt 409 means the row already moved on to another attempt
|
|
578
932
|
// (requeued and re-delivered); the requeue would 409 the same way, and
|
|
579
933
|
// the newer attempt owes the row its answer — drop this one.
|
|
580
934
|
if (isStaleAttemptError(error)) {
|
|
581
935
|
dbg('response refused as stale_attempt; dropping', message.id);
|
|
582
|
-
return;
|
|
936
|
+
return 'stale';
|
|
583
937
|
}
|
|
584
|
-
|
|
585
|
-
|
|
938
|
+
if (kind || !requeueOnFailure) return 'failed';
|
|
939
|
+
const requeued = await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) })
|
|
940
|
+
.then(() => true, () => false);
|
|
941
|
+
return requeued ? 'requeued' : 'failed';
|
|
942
|
+
}
|
|
586
943
|
}
|
|
587
944
|
|
|
588
945
|
function terminalErrorRecord(message, classified) {
|
|
@@ -596,6 +953,117 @@ export function createCopilotSdkSessionRunner({
|
|
|
596
953
|
};
|
|
597
954
|
}
|
|
598
955
|
|
|
956
|
+
// ------------------------------------------------- at-most-once settling --
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* Claim a consumed row as settling — synchronously, BEFORE anything stops
|
|
960
|
+
* reporting it as live, so no heartbeat can see it unowned. `variant` picks
|
|
961
|
+
* the terminal wording should its settle never land.
|
|
962
|
+
*/
|
|
963
|
+
function claimSettling(message, variant = 'folded') {
|
|
964
|
+
const id = String(message?.id || '').trim();
|
|
965
|
+
if (id && !settlingMessages.has(id)) settlingMessages.set(id, { message, variant });
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/** Release a settled row — unless it was handed to the relay to fail. */
|
|
969
|
+
function releaseSettling(id) {
|
|
970
|
+
const key = String(id || '').trim();
|
|
971
|
+
if (!settleFailedTerminals.has(key)) settlingMessages.delete(key);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Mark rows whose prompt the runtime has folded into a running turn as
|
|
976
|
+
* consumed on the relay (the moment `user.message{delivery:"steering"}`
|
|
977
|
+
* confirms it). Best-effort: the worker's own settle still owns these rows
|
|
978
|
+
* while it lives; the mark only protects them when it does not — a crash
|
|
979
|
+
* before the settle marker lands must fail the row, never re-run it.
|
|
980
|
+
*/
|
|
981
|
+
function markConsumed(conversationId, entries = [], { consumed = true } = {}) {
|
|
982
|
+
const rows = entries
|
|
983
|
+
.map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
|
|
984
|
+
.filter((entry) => entry.id);
|
|
985
|
+
if (!rows.length) return Promise.resolve();
|
|
986
|
+
const previous = Promise.allSettled(rows.map((row) => consumedMarkChains.get(row.id)).filter(Boolean));
|
|
987
|
+
const post = previous.then(() => api('POST', '/api/queue-consumed', {
|
|
988
|
+
conversationId: String(conversationId || sdkSessionId || ''),
|
|
989
|
+
entries: rows,
|
|
990
|
+
...(consumed ? {} : { consumed: false }),
|
|
991
|
+
})).catch((error) => {
|
|
992
|
+
dbg('consumed mark failed', rows.map((entry) => entry.id).join(','), error?.message || String(error));
|
|
993
|
+
});
|
|
994
|
+
for (const row of rows) consumedMarkChains.set(row.id, post);
|
|
995
|
+
post.then(() => {
|
|
996
|
+
for (const row of rows) if (consumedMarkChains.get(row.id) === post) consumedMarkChains.delete(row.id);
|
|
997
|
+
});
|
|
998
|
+
return post;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** Heartbeat payload: rows whose terminal failure the relay must post. */
|
|
1002
|
+
function getSettleFailed() {
|
|
1003
|
+
return [...settleFailedTerminals.entries()].map(([id, terminalError]) => ({
|
|
1004
|
+
id,
|
|
1005
|
+
attemptId: settlingMessages.get(id)?.message?.attemptId || null,
|
|
1006
|
+
terminalError,
|
|
1007
|
+
}));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function acknowledgeSettleFailed(ids = []) {
|
|
1011
|
+
for (const id of ids) {
|
|
1012
|
+
const key = String(id || '').trim();
|
|
1013
|
+
if (!settleFailedTerminals.delete(key)) continue;
|
|
1014
|
+
settlingMessages.delete(key);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const sleep = (ms) => new Promise((resolve) => { const t = setTimeout(resolve, ms); t.unref?.(); });
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Save the settle marker of a steered message the runtime already consumed
|
|
1022
|
+
* — at most once. The row stays in `settlingMessages`, and so in every
|
|
1023
|
+
* heartbeat, until the relay commits the marker. A marker that cannot be
|
|
1024
|
+
* saved within the retry budget ends as a terminal failure — never a
|
|
1025
|
+
* requeue — and if even that cannot be posted, the heartbeat hands the row
|
|
1026
|
+
* to the relay to fail. Ported from the Claude worker; same guarantees.
|
|
1027
|
+
*/
|
|
1028
|
+
async function publishSettleMarker(message, { text, model, kind, variant = 'folded' }) {
|
|
1029
|
+
const id = String(message?.id || '').trim();
|
|
1030
|
+
if (!id) return;
|
|
1031
|
+
claimSettling(message, variant);
|
|
1032
|
+
try {
|
|
1033
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
1034
|
+
const outcome = await publishResponse(message, { text, model, kind });
|
|
1035
|
+
if (outcome !== 'failed') return;
|
|
1036
|
+
if (attempt >= settleRetryDelaysMs.length) break;
|
|
1037
|
+
await sleep(settleRetryDelaysMs[attempt]);
|
|
1038
|
+
}
|
|
1039
|
+
dbg('settle marker could not be saved; failing the row terminally', id);
|
|
1040
|
+
const terminalError = buildSteerSettleFailure(message, { variant, agentLabel: SETTLE_AGENT_LABEL });
|
|
1041
|
+
const retryMs = Math.max(100, Number(settleRetryDelaysMs.at(-1)) || 0);
|
|
1042
|
+
for (let attempt = 0; attempt < Math.max(1, maxTerminalSettleAttempts); attempt += 1) {
|
|
1043
|
+
if (attempt) await sleep(retryMs);
|
|
1044
|
+
const outcome = await publishResponse(message, {
|
|
1045
|
+
text: terminalError.message,
|
|
1046
|
+
model,
|
|
1047
|
+
terminalError,
|
|
1048
|
+
requeueOnFailure: false,
|
|
1049
|
+
});
|
|
1050
|
+
if (outcome !== 'failed') return;
|
|
1051
|
+
// Second channel: the requeue route fails a row outright when handed
|
|
1052
|
+
// a terminal error.
|
|
1053
|
+
const failedViaRequeue = await api('POST', '/api/requeue', {
|
|
1054
|
+
messageId: id,
|
|
1055
|
+
terminalError,
|
|
1056
|
+
...attemptFields(message),
|
|
1057
|
+
}).then(() => true, (error) => isStaleAttemptError(error));
|
|
1058
|
+
if (failedViaRequeue) return;
|
|
1059
|
+
}
|
|
1060
|
+
dbg('terminal settle could not be posted; handing it to the heartbeat', id);
|
|
1061
|
+
settleFailedTerminals.set(id, terminalError);
|
|
1062
|
+
} finally {
|
|
1063
|
+
releaseSettling(id);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
599
1067
|
/**
|
|
600
1068
|
* Per-turn usage capture, recorded on the runner.
|
|
601
1069
|
*
|
|
@@ -731,16 +1199,34 @@ export function createCopilotSdkSessionRunner({
|
|
|
731
1199
|
return;
|
|
732
1200
|
}
|
|
733
1201
|
touch();
|
|
1202
|
+
observeRecalls(event);
|
|
734
1203
|
observeBackgroundShells(event);
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
//
|
|
739
|
-
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
|
|
1204
|
+
observeBackgroundAgents(event);
|
|
1205
|
+
observeCompaction(event);
|
|
1206
|
+
observeQueueLane(event);
|
|
1207
|
+
// A background subagent's spawn emits a SESSION-level
|
|
1208
|
+
// `session.model_change{source:"agent"}` for the subagent's own model
|
|
1209
|
+
// (live-probed on 1.0.88). It is not the main thread's model: feeding it
|
|
1210
|
+
// to the switcher would confirm a pending switch with the wrong model and
|
|
1211
|
+
// move the catalog's currentModel.
|
|
1212
|
+
const agentModelChange = event?.type === 'session.model_change'
|
|
1213
|
+
&& String(event?.data?.source || '').trim().toLowerCase() === 'agent';
|
|
1214
|
+
if (!agentModelChange) {
|
|
1215
|
+
// Keeps the confirmed-model tracking truthful for switches this worker
|
|
1216
|
+
// did not ask for, and resolves the bounded wait on a deferred switch's
|
|
1217
|
+
// drain. Behind the replay gate on purpose: a historical
|
|
1218
|
+
// `session.model_change` must not confirm a pending switch.
|
|
1219
|
+
modelSwitch.observeEvent(event);
|
|
1220
|
+
// A LIVE model change moves the catalog's currentModel; replays cannot
|
|
1221
|
+
// get here (the gate above), so this cannot re-publish days-old state.
|
|
1222
|
+
if (event?.type === 'session.model_change') publishModelSnapshot('model-change');
|
|
1223
|
+
}
|
|
743
1224
|
let turn = activeTurn;
|
|
1225
|
+
if (!turn) {
|
|
1226
|
+
// A prompt this worker pushed whose run the runtime opened after its
|
|
1227
|
+
// turn had already settled is that row's own turn, not a continuation.
|
|
1228
|
+
turn = adoptOrphanedRun(event);
|
|
1229
|
+
}
|
|
744
1230
|
if (!turn) {
|
|
745
1231
|
// The runtime started work with no row open. Anything that is not the
|
|
746
1232
|
// START of work (terminators, connection bookkeeping) stays a no-op:
|
|
@@ -777,15 +1263,396 @@ export function createCopilotSdkSessionRunner({
|
|
|
777
1263
|
* backgroundWorkHoldsRuntime. Advisory: a failed post never disturbs events.
|
|
778
1264
|
*/
|
|
779
1265
|
function publishBackgroundShellTasks() {
|
|
780
|
-
|
|
1266
|
+
publishBackgroundTasks({ immediate: true });
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/** The panel rows for the shell tracker's set — the path without `rpc.tasks`. */
|
|
1270
|
+
function shellTrackerCards() {
|
|
1271
|
+
return backgroundShells.live().map((shell) => ({
|
|
781
1272
|
taskId: shell.shellId,
|
|
782
1273
|
taskType: 'local_bash',
|
|
783
1274
|
description: shell.description || 'Detached shell',
|
|
784
1275
|
startedAt: shell.startedAt || null,
|
|
785
1276
|
stoppable: false,
|
|
786
1277
|
}));
|
|
787
|
-
|
|
788
|
-
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* The panel rows for the runtime's task registry: background agents and
|
|
1282
|
+
* shells still running, each with what the events added. `stoppable` is
|
|
1283
|
+
* true because `rpc.tasks.cancel` is present whenever this path is taken.
|
|
1284
|
+
*/
|
|
1285
|
+
function registryCards() {
|
|
1286
|
+
const cards = [];
|
|
1287
|
+
for (const task of backgroundTasks.values()) {
|
|
1288
|
+
if (task.status !== 'running') continue;
|
|
1289
|
+
const startedAt = Date.parse(String(task.startedAt || '')) || null;
|
|
1290
|
+
if (task.type === 'shell') {
|
|
1291
|
+
cards.push({
|
|
1292
|
+
taskId: task.id,
|
|
1293
|
+
taskType: 'local_bash',
|
|
1294
|
+
description: task.description || task.command || 'Background shell',
|
|
1295
|
+
startedAt,
|
|
1296
|
+
stoppable: true,
|
|
1297
|
+
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1298
|
+
});
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
if (task.type !== 'agent') continue;
|
|
1302
|
+
const detail = agentDetails.get(task.id) || {};
|
|
1303
|
+
const model = task.resolvedModel || task.model || detail.model || '';
|
|
1304
|
+
const inherited = !model;
|
|
1305
|
+
cards.push({
|
|
1306
|
+
taskId: task.id,
|
|
1307
|
+
taskType: 'local_agent',
|
|
1308
|
+
description: task.description || task.displayName || detail.displayName || 'Background agent',
|
|
1309
|
+
startedAt: startedAt || detail.startedAt || null,
|
|
1310
|
+
stoppable: true,
|
|
1311
|
+
subagentType: task.agentType || detail.agentType || null,
|
|
1312
|
+
model: model || appliedModel() || defaultModel || null,
|
|
1313
|
+
modelInherited: inherited,
|
|
1314
|
+
...(detail.lastToolCall ? { lastToolCall: detail.lastToolCall } : {}),
|
|
1315
|
+
...(Number.isFinite(detail.totalTokens) && detail.totalTokens > 0 ? { totalTokens: detail.totalTokens } : {}),
|
|
1316
|
+
...(task.progressSummary ? { summary: task.progressSummary } : {}),
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1319
|
+
return cards;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Mirror the live background work into the relay's task panel (REPLACE
|
|
1324
|
+
* semantics, the same route the Claude worker publishes on). Set changes
|
|
1325
|
+
* publish at once; enrichment-only changes (a tool call, tokens) ride a
|
|
1326
|
+
* trailing throttle. Advisory: a failed post never disturbs events.
|
|
1327
|
+
*/
|
|
1328
|
+
function publishBackgroundTasks({ immediate = false } = {}) {
|
|
1329
|
+
const run = () => {
|
|
1330
|
+
if (taskPublishTimer) {
|
|
1331
|
+
clearTimeout(taskPublishTimer);
|
|
1332
|
+
taskPublishTimer = null;
|
|
1333
|
+
}
|
|
1334
|
+
const tasks = sessionRpc.tasks ? registryCards() : shellTrackerCards();
|
|
1335
|
+
const signature = JSON.stringify(tasks);
|
|
1336
|
+
if (signature === lastPublishedTasksSignature) return;
|
|
1337
|
+
lastPublishedTasksSignature = signature;
|
|
1338
|
+
api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks })
|
|
1339
|
+
.catch((error) => dbg('background task publish failed', error?.message || String(error)));
|
|
1340
|
+
};
|
|
1341
|
+
if (immediate || !(taskPublishThrottleMs > 0)) {
|
|
1342
|
+
run();
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
if (taskPublishTimer) return;
|
|
1346
|
+
taskPublishTimer = setTimeout(run, taskPublishThrottleMs);
|
|
1347
|
+
taskPublishTimer.unref?.();
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
// ------------------------------------------------------- background tasks --
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* What the event stream knows about background agents that the registry
|
|
1354
|
+
* does not: spawn details, the live tool call, a running token count, and
|
|
1355
|
+
* the completion heralds. `session.background_tasks_changed` itself is
|
|
1356
|
+
* empty and only says "re-read the registry".
|
|
1357
|
+
*/
|
|
1358
|
+
function observeBackgroundAgents(event) {
|
|
1359
|
+
const type = String(event?.type || '');
|
|
1360
|
+
const data = event?.data && typeof event.data === 'object' ? event.data : {};
|
|
1361
|
+
const agentId = String(event?.agentId || '').trim();
|
|
1362
|
+
if (type === 'session.background_tasks_changed') {
|
|
1363
|
+
scheduleTaskRefresh();
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (type === 'subagent.started' && String(data.executionMode || '').trim().toLowerCase() === 'background' && agentId) {
|
|
1367
|
+
// Kept for the life of the session (a finished agent's late events must
|
|
1368
|
+
// still be recognised as background), bounded so a long session cannot
|
|
1369
|
+
// grow it forever.
|
|
1370
|
+
while (agentDetails.size >= 500) agentDetails.delete(agentDetails.keys().next().value);
|
|
1371
|
+
agentDetails.set(agentId, {
|
|
1372
|
+
displayName: String(data.agentDisplayName || data.agentName || '').trim(),
|
|
1373
|
+
agentType: String(data.agentType || '').trim(),
|
|
1374
|
+
model: String(data.model || '').trim(),
|
|
1375
|
+
lastToolCall: '',
|
|
1376
|
+
totalTokens: 0,
|
|
1377
|
+
startedAt: Date.now(),
|
|
1378
|
+
});
|
|
1379
|
+
scheduleTaskRefresh();
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
const settledAgent = settledAgentFromNotification(event);
|
|
1383
|
+
if (settledAgent) {
|
|
1384
|
+
dbg('background agent settled', settledAgent.agentId, settledAgent.status);
|
|
1385
|
+
// The runtime is about to re-invoke the model off the back of this
|
|
1386
|
+
// (`read_agent` + a reply): pin the runtime for the continuation, and
|
|
1387
|
+
// carry a line into the turn it triggers when no turn is open to take
|
|
1388
|
+
// it. Same shape as a settled shell's herald.
|
|
1389
|
+
continuationDueSince = Date.now();
|
|
1390
|
+
const note = describeSettledAgent(settledAgent);
|
|
1391
|
+
if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
|
|
1392
|
+
pendingActivities.push({ text: note, reportsFailure: settledAgent.status === 'failed' });
|
|
1393
|
+
}
|
|
1394
|
+
scheduleTaskRefresh();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
if (agentId && agentDetails.has(agentId)) {
|
|
1398
|
+
const detail = agentDetails.get(agentId);
|
|
1399
|
+
if (type === 'tool.execution_start') {
|
|
1400
|
+
detail.lastToolCall = formatToolActivityText(data.toolName, data.arguments);
|
|
1401
|
+
publishBackgroundTasks();
|
|
1402
|
+
} else if (type === 'assistant.usage') {
|
|
1403
|
+
const used = (Number(data.inputTokens) || 0) + (Number(data.outputTokens) || 0);
|
|
1404
|
+
if (used > 0) {
|
|
1405
|
+
detail.totalTokens += used;
|
|
1406
|
+
publishBackgroundTasks();
|
|
1407
|
+
}
|
|
1408
|
+
} else if (type === 'subagent.completed' || type === 'subagent.failed') {
|
|
1409
|
+
const total = Number(data.totalTokens);
|
|
1410
|
+
if (Number.isFinite(total) && total > 0) detail.totalTokens = total;
|
|
1411
|
+
scheduleTaskRefresh();
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function scheduleTaskRefresh() {
|
|
1417
|
+
if (!sessionRpc.tasks || taskRefreshTimer) return;
|
|
1418
|
+
taskRefreshTimer = setTimeout(() => {
|
|
1419
|
+
taskRefreshTimer = null;
|
|
1420
|
+
taskRefreshChain = taskRefreshChain.then(() => refreshBackgroundTasks()).catch(() => {});
|
|
1421
|
+
}, taskRefreshMs);
|
|
1422
|
+
taskRefreshTimer.unref?.();
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Re-read the runtime's task registry. Running agents and shells become
|
|
1427
|
+
* cards (with a progress line when the runtime has one); finished entries
|
|
1428
|
+
* are removed from the runtime's tracking so the registry cannot grow for
|
|
1429
|
+
* the life of the session. A refresh that fails leaves the last read in
|
|
1430
|
+
* place — a stale card beats a vanished one.
|
|
1431
|
+
*/
|
|
1432
|
+
async function refreshBackgroundTasks() {
|
|
1433
|
+
const target = session;
|
|
1434
|
+
if (!target || !sessionRpc.tasks) return;
|
|
1435
|
+
lastTaskRefreshAt = Date.now();
|
|
1436
|
+
let listed;
|
|
1437
|
+
try {
|
|
1438
|
+
listed = await target.rpc.tasks.list();
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
dbg('tasks.list failed', error?.message || String(error));
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
if (session !== target) return;
|
|
1444
|
+
const next = new Map();
|
|
1445
|
+
for (const task of Array.isArray(listed?.tasks) ? listed.tasks : []) {
|
|
1446
|
+
const id = String(task?.id || '').trim();
|
|
1447
|
+
if (!id || abandonedTasks.has(id)) continue;
|
|
1448
|
+
const status = String(task?.status || '').trim().toLowerCase();
|
|
1449
|
+
// Only running tasks are live. 'idle' (a multi-turn agent waiting for
|
|
1450
|
+
// messages) holds nothing and shows nowhere until it is messaged
|
|
1451
|
+
// (roadmap: tasks.sendMessage); finished entries are left to the
|
|
1452
|
+
// runtime's own tracking — removing them here could race the root
|
|
1453
|
+
// agent's `read_agent` follow-up that the completion notification
|
|
1454
|
+
// triggers.
|
|
1455
|
+
if (status === 'running') {
|
|
1456
|
+
next.set(id, { ...task, id, status, progressSummary: backgroundTasks.get(id)?.progressSummary || '' });
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
// Progress lines for the live set: the agent's latest intent / last
|
|
1460
|
+
// activity, a shell's last output line. One RPC per live task per refresh
|
|
1461
|
+
// (refreshes are debounced), best effort.
|
|
1462
|
+
if (typeof target.rpc.tasks.getProgress === 'function') {
|
|
1463
|
+
for (const task of next.values()) {
|
|
1464
|
+
try {
|
|
1465
|
+
const progress = (await target.rpc.tasks.getProgress({ id: task.id }))?.progress || null;
|
|
1466
|
+
task.progressSummary = summarizeTaskProgress(progress);
|
|
1467
|
+
} catch { /* a card without a progress line */ }
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
if (session !== target) return;
|
|
1471
|
+
const setChanged = next.size !== backgroundTasks.size
|
|
1472
|
+
|| [...next.keys()].some((id) => !backgroundTasks.has(id));
|
|
1473
|
+
backgroundTasks = next;
|
|
1474
|
+
publishBackgroundTasks({ immediate: setChanged });
|
|
1475
|
+
// The lifecycle poll (every `lifecyclePollMs`) picks the new set up; a
|
|
1476
|
+
// re-evaluation here would re-schedule a refresh and spin.
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function summarizeTaskProgress(progress) {
|
|
1480
|
+
if (!progress || typeof progress !== 'object') return '';
|
|
1481
|
+
if (progress.type === 'agent') {
|
|
1482
|
+
const intent = String(progress.latestIntent || '').trim();
|
|
1483
|
+
if (intent) return intent.slice(0, 300);
|
|
1484
|
+
const lines = Array.isArray(progress.recentActivity) ? progress.recentActivity : [];
|
|
1485
|
+
const last = lines.length ? String(lines[lines.length - 1]?.message || '').trim() : '';
|
|
1486
|
+
return last.slice(0, 300);
|
|
1487
|
+
}
|
|
1488
|
+
if (progress.type === 'shell') {
|
|
1489
|
+
const output = String(progress.recentOutput || '').trim();
|
|
1490
|
+
if (!output || output === '(no output yet)') return '';
|
|
1491
|
+
const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
1492
|
+
return (lines[lines.length - 1] || '').slice(0, 300);
|
|
1493
|
+
}
|
|
1494
|
+
return '';
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** The live tasks from the registry (running agents + shells). */
|
|
1498
|
+
function runningBackgroundTasks() {
|
|
1499
|
+
return [...backgroundTasks.values()].filter((task) => task.status === 'running');
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/**
|
|
1503
|
+
* Stop one background task (the panel's per-card Stop, or a subagent lane's
|
|
1504
|
+
* Stop for a background agent). Returns whether the runtime cancelled it.
|
|
1505
|
+
*/
|
|
1506
|
+
async function stopBackgroundTask(taskId) {
|
|
1507
|
+
const id = String(taskId || '').trim();
|
|
1508
|
+
if (!id || !session || !sessionRpc.tasks) return false;
|
|
1509
|
+
try {
|
|
1510
|
+
const outcome = await session.rpc.tasks.cancel({ id });
|
|
1511
|
+
const cancelled = outcome?.cancelled === true;
|
|
1512
|
+
dbg(cancelled ? 'background task cancelled' : 'background task cancel refused', id);
|
|
1513
|
+
if (cancelled) scheduleTaskRefresh();
|
|
1514
|
+
return cancelled;
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
dbg('tasks.cancel failed', id, error?.message || String(error));
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
/**
|
|
1524
|
+
* The compaction hold. A message pushed while the runtime is compacting
|
|
1525
|
+
* would be injected into the compaction's replay, so steering is held from
|
|
1526
|
+
* `session.compaction_start` to `_complete` (the runtime queues messages
|
|
1527
|
+
* sent during a compaction itself, but holding them relay-side keeps the
|
|
1528
|
+
* bubble cancellable and the composer truthful — the Claude rule).
|
|
1529
|
+
*/
|
|
1530
|
+
function observeCompaction(event) {
|
|
1531
|
+
const type = String(event?.type || '');
|
|
1532
|
+
if (event?.agentId) return;
|
|
1533
|
+
if (type === 'session.compaction_start') {
|
|
1534
|
+
compactingSince = Date.now();
|
|
1535
|
+
syncDeliveryReadiness();
|
|
1536
|
+
} else if (type === 'session.compaction_complete') {
|
|
1537
|
+
compactingSince = 0;
|
|
1538
|
+
syncDeliveryReadiness();
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function isCompacting() {
|
|
1543
|
+
if (!compactingSince) return false;
|
|
1544
|
+
if (Date.now() - compactingSince < compactionStaleMs) return true;
|
|
1545
|
+
dbg('compaction hold went stale; releasing it');
|
|
1546
|
+
compactingSince = 0;
|
|
1547
|
+
return false;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Keep `queuedLane` current. `pending_messages.modified` carries no payload
|
|
1552
|
+
* (it only says "the queue changed"), so it schedules a debounced
|
|
1553
|
+
* `rpc.queue.pendingItems()` read. What matters here is which pushed rows
|
|
1554
|
+
* are still in the QUEUED lane: those the runtime has not consumed and the
|
|
1555
|
+
* client may still cancel (`cancellableIds` on the steering snapshot).
|
|
1556
|
+
*/
|
|
1557
|
+
function observeQueueLane(event) {
|
|
1558
|
+
if (String(event?.type || '') !== 'pending_messages.modified') return;
|
|
1559
|
+
scheduleQueueLaneRefresh();
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
/** Un-steer's synchronous view of the runtime taking a prompt (see `recallWatches`). */
|
|
1563
|
+
function observeRecalls(event) {
|
|
1564
|
+
if (String(event?.type || '') !== 'user.message' || event?.agentId) return;
|
|
1565
|
+
const runtimeId = String(event?.data?.messageId || '').trim();
|
|
1566
|
+
if (!runtimeId) return;
|
|
1567
|
+
const watch = recallWatches.get(runtimeId);
|
|
1568
|
+
if (watch) watch.taken = true;
|
|
1569
|
+
const recalledRow = recalledRuntimeIds.get(runtimeId);
|
|
1570
|
+
if (recalledRow) {
|
|
1571
|
+
dbg('UN-STEER MISMATCH: a prompt recalled and cancelled is running after all; the remove took another message',
|
|
1572
|
+
recalledRow, runtimeId);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function scheduleQueueLaneRefresh() {
|
|
1577
|
+
if (!sessionRpc.queue || queueLaneTimer) return;
|
|
1578
|
+
queueLaneTimer = setTimeout(() => {
|
|
1579
|
+
queueLaneTimer = null;
|
|
1580
|
+
queueLaneRefreshChain = queueLaneRefreshChain.then(() => refreshQueueLane()).catch(() => {});
|
|
1581
|
+
}, queueLaneRefreshMs);
|
|
1582
|
+
queueLaneTimer.unref?.();
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
async function refreshQueueLane() {
|
|
1586
|
+
const target = session;
|
|
1587
|
+
if (!target || !sessionRpc.queue) return;
|
|
1588
|
+
try {
|
|
1589
|
+
const snapshot = await target.rpc.queue.pendingItems();
|
|
1590
|
+
const next = new Map();
|
|
1591
|
+
for (const item of Array.isArray(snapshot?.items) ? snapshot.items : []) {
|
|
1592
|
+
const runtimeId = String(item?.messageId || '').trim();
|
|
1593
|
+
const itemId = String(item?.id || '').trim();
|
|
1594
|
+
if (runtimeId && itemId) next.set(runtimeId, itemId);
|
|
1595
|
+
}
|
|
1596
|
+
if (session !== target) return;
|
|
1597
|
+
queuedLane = next;
|
|
1598
|
+
queuedItemCount = Array.isArray(snapshot?.items) ? snapshot.items.length : 0;
|
|
1599
|
+
const steering = Array.isArray(snapshot?.steeringMessages) ? snapshot.steeringMessages.length : 0;
|
|
1600
|
+
const inFlight = Number(snapshot?.inFlightSteeringCount) || 0;
|
|
1601
|
+
waitingSteers = Math.max(0, steering - inFlight);
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
dbg('queue.pendingItems failed', error?.message || String(error));
|
|
1604
|
+
}
|
|
1605
|
+
noteCancellableChange();
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/** Tell the worker when the recallable set changed (see onCancellableChange). */
|
|
1609
|
+
function noteCancellableChange() {
|
|
1610
|
+
const signature = cancellableIds().join(',');
|
|
1611
|
+
if (signature === lastCancellableSignature) return;
|
|
1612
|
+
lastCancellableSignature = signature;
|
|
1613
|
+
try {
|
|
1614
|
+
onCancellableChange();
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
dbg('cancellable listener failed', error?.message || String(error));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/** Our pushed steers the runtime has not started on yet, in send order. */
|
|
1621
|
+
function unconsumedSteers() {
|
|
1622
|
+
const list = [];
|
|
1623
|
+
for (const entry of ownedEntries()) {
|
|
1624
|
+
if (entry.role === 'steer' && !entry.consumed && !entry.settled && entry.runtimeId) list.push(entry);
|
|
1625
|
+
}
|
|
1626
|
+
return list.sort((a, b) => (a.sendSeq || 0) - (b.sendSeq || 0));
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* How `entry` can still be pulled back out of the runtime, or null: by its
|
|
1631
|
+
* stable id when it sits in the queued lane, or with removeMostRecent when
|
|
1632
|
+
* it is the runtime's newest waiting message (our newest unconsumed steer,
|
|
1633
|
+
* at least one steer waiting, nothing in the queued lane that a LIFO remove
|
|
1634
|
+
* would take instead).
|
|
1635
|
+
*/
|
|
1636
|
+
function recallMethodFor(entry) {
|
|
1637
|
+
if (!entry || entry.role !== 'steer' || entry.consumed || entry.settled || !entry.runtimeId) return null;
|
|
1638
|
+
const itemId = queuedLane.get(entry.runtimeId);
|
|
1639
|
+
if (itemId) return { kind: 'queued', itemId };
|
|
1640
|
+
if (typeof session?.rpc?.queue?.removeMostRecent !== 'function') return null;
|
|
1641
|
+
if (steerSendsInFlight > 0 || queuedItemCount > 0 || waitingSteers < 1) return null;
|
|
1642
|
+
const pending = unconsumedSteers();
|
|
1643
|
+
return pending.length && pending[pending.length - 1] === entry ? { kind: 'newest' } : null;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/** Probe the experimental RPC surfaces once per session. */
|
|
1647
|
+
function probeSessionRpc(target) {
|
|
1648
|
+
sessionRpc = {
|
|
1649
|
+
queue: typeof target?.rpc?.queue?.pendingItems === 'function'
|
|
1650
|
+
&& typeof target?.rpc?.queue?.removeAt === 'function',
|
|
1651
|
+
interruptMainTurn: typeof target?.rpc?.interruptMainTurn === 'function',
|
|
1652
|
+
tasks: typeof target?.rpc?.tasks?.list === 'function'
|
|
1653
|
+
&& typeof target?.rpc?.tasks?.cancel === 'function',
|
|
1654
|
+
};
|
|
1655
|
+
dbg(`copilot session rpc surfaces: queue=${sessionRpc.queue} interruptMainTurn=${sessionRpc.interruptMainTurn} tasks=${sessionRpc.tasks}`);
|
|
789
1656
|
}
|
|
790
1657
|
|
|
791
1658
|
function observeBackgroundShells(event) {
|
|
@@ -809,7 +1676,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
809
1676
|
// closed to new lines, so the note belongs to the continuation this
|
|
810
1677
|
// notification is about to trigger.
|
|
811
1678
|
if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
|
|
812
|
-
pendingActivities.push(note);
|
|
1679
|
+
pendingActivities.push({ text: note, reportsFailure: false });
|
|
813
1680
|
}
|
|
814
1681
|
}
|
|
815
1682
|
// Only the runtime's own `system.notification` heralds a continuation. A
|
|
@@ -835,9 +1702,13 @@ export function createCopilotSdkSessionRunner({
|
|
|
835
1702
|
// — but it is guarded anyway: re-routing INTO a settled turn would recurse
|
|
836
1703
|
// through this function forever, so it is treated as "no owner".
|
|
837
1704
|
if (!turn || turn.settled) {
|
|
838
|
-
// Same
|
|
839
|
-
|
|
840
|
-
turn =
|
|
1705
|
+
// Same rules as `routeEvent`: a pushed prompt's late run is its own
|
|
1706
|
+
// turn; otherwise only the start of work opens a row.
|
|
1707
|
+
turn = adoptOrphanedRun(event);
|
|
1708
|
+
if (!turn) {
|
|
1709
|
+
if (!isContinuationOpeningEvent(event)) return;
|
|
1710
|
+
turn = openContinuationTurn();
|
|
1711
|
+
}
|
|
841
1712
|
}
|
|
842
1713
|
await handleTurnEvent(turn, event);
|
|
843
1714
|
}
|
|
@@ -848,6 +1719,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
848
1719
|
return;
|
|
849
1720
|
}
|
|
850
1721
|
turn.armStall?.();
|
|
1722
|
+
// Root work ends on the runtime's own terminator, so a question-only
|
|
1723
|
+
// continuation it joins no longer closes itself (whileAwaitingHuman).
|
|
1724
|
+
if (isContinuationOpeningEvent(event)) turn.rootWork = true;
|
|
851
1725
|
let actions = [];
|
|
852
1726
|
try {
|
|
853
1727
|
actions = turn.normalizer.normalize(event);
|
|
@@ -855,6 +1729,12 @@ export function createCopilotSdkSessionRunner({
|
|
|
855
1729
|
dbg('normalize failed', String(event?.type || ''), error?.message || String(error));
|
|
856
1730
|
return;
|
|
857
1731
|
}
|
|
1732
|
+
// The prompt boundary: the runtime has started work on a prompt. Decides
|
|
1733
|
+
// which row owns the segment the normalizer just opened, and settles the
|
|
1734
|
+
// previous run's row when this one is a new run (see noteUserMessage).
|
|
1735
|
+
if (String(event?.type || '') === 'user.message' && !event?.agentId) {
|
|
1736
|
+
await noteUserMessage(turn, event);
|
|
1737
|
+
}
|
|
858
1738
|
for (const action of actions) {
|
|
859
1739
|
if (action.channel === 'result') {
|
|
860
1740
|
turn.result = action.payload;
|
|
@@ -874,27 +1754,150 @@ export function createCopilotSdkSessionRunner({
|
|
|
874
1754
|
while (turn.bufferedActions.length) {
|
|
875
1755
|
const batch = turn.bufferedActions.splice(0);
|
|
876
1756
|
for (const action of batch) {
|
|
877
|
-
await
|
|
1757
|
+
await dispatchOwned(turn, action).catch(() => {});
|
|
878
1758
|
}
|
|
879
1759
|
}
|
|
880
1760
|
}
|
|
881
1761
|
|
|
882
|
-
|
|
1762
|
+
/**
|
|
1763
|
+
* The relay's `ask_user` tool: same name and fields as the runtime's
|
|
1764
|
+
* built-in, plus `multi_select`. The handler runs under exactly the same
|
|
1765
|
+
* human-gating as the built-in path (`onUserInputRequest`): the stall
|
|
1766
|
+
* watchdog is held, steering is held (a question card is open), and a card
|
|
1767
|
+
* raised the moment a continuation opens waits for its row. It never
|
|
1768
|
+
* throws: a failed card answers in-band so the model can carry on.
|
|
1769
|
+
*/
|
|
1770
|
+
function buildRelayAskUserTool() {
|
|
1771
|
+
return {
|
|
1772
|
+
name: RELAY_ASK_USER_TOOL_NAME,
|
|
1773
|
+
description: RELAY_ASK_USER_TOOL_DESCRIPTION,
|
|
1774
|
+
parameters: RELAY_ASK_USER_TOOL_PARAMETERS,
|
|
1775
|
+
overridesBuiltInTool: true,
|
|
1776
|
+
// The built-in asks no permission either: asking a question is not an action.
|
|
1777
|
+
skipPermission: true,
|
|
1778
|
+
defer: 'never',
|
|
1779
|
+
handler: async (args, invocation) => {
|
|
1780
|
+
const { question, choices, multiSelect } = parseAskUserToolArguments(args);
|
|
1781
|
+
try {
|
|
1782
|
+
const result = await whileAwaitingHuman(async (turn) => {
|
|
1783
|
+
await awaitInteractiveRow(turn);
|
|
1784
|
+
// Cancelled by either side: the turn (Stop, teardown) or the
|
|
1785
|
+
// runtime itself (a subagent cancelled via tasks.cancel, a
|
|
1786
|
+
// disconnect) — a card nobody will read must not hold steering.
|
|
1787
|
+
const signal = combineSignals(turn?.abortController?.signal, invocation?.signal);
|
|
1788
|
+
return questionBridge.askUserInput(
|
|
1789
|
+
{ question, choices, allowFreeform: true, multiSelect, source: 'relay-ask-user-tool' },
|
|
1790
|
+
{ signal, message: turn?.message },
|
|
1791
|
+
);
|
|
1792
|
+
});
|
|
1793
|
+
return formatAskUserToolResult({
|
|
1794
|
+
answer: result?.answer,
|
|
1795
|
+
wasFreeform: result?.wasFreeform !== false,
|
|
1796
|
+
timedOut: result?.timedOut === true,
|
|
1797
|
+
});
|
|
1798
|
+
} catch (error) {
|
|
1799
|
+
dbg('relay ask_user failed', error?.message || String(error));
|
|
1800
|
+
return USER_INPUT_UNSUPPORTED_ANSWER;
|
|
1801
|
+
}
|
|
1802
|
+
},
|
|
1803
|
+
};
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* The relay's `remote_relay` tool (copilot-remote-relay-tool.mjs). Every call
|
|
1808
|
+
* is counted while it runs: the stall watchdog re-arms instead of failing the
|
|
1809
|
+
* row, and the lifecycle keeps the runtime up. The clock restarts from the
|
|
1810
|
+
* answer, like after a card. A call is cancelled by the runtime (its
|
|
1811
|
+
* invocation signal) or by a Stop of the turn it started under.
|
|
1812
|
+
*/
|
|
1813
|
+
function buildRemoteRelayTool() {
|
|
1814
|
+
return buildCopilotRemoteRelayTool({
|
|
1815
|
+
api,
|
|
1816
|
+
getConversationId: () => activeTurn?.message?.conversationId || sdkSessionId || '',
|
|
1817
|
+
getSignal: (invocation) => combineSignals(activeTurn?.remoteRelayStop?.signal, invocation?.signal),
|
|
1818
|
+
runCall: async (fn) => {
|
|
1819
|
+
pendingRelayToolCalls += 1;
|
|
1820
|
+
try {
|
|
1821
|
+
return await fn();
|
|
1822
|
+
} finally {
|
|
1823
|
+
pendingRelayToolCalls -= 1;
|
|
1824
|
+
touch();
|
|
1825
|
+
activeTurn?.armStall?.();
|
|
1826
|
+
}
|
|
1827
|
+
},
|
|
1828
|
+
dbg,
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/** One signal that aborts when any of the given ones does (nulls ignored). */
|
|
1833
|
+
function combineSignals(...signals) {
|
|
1834
|
+
const live = signals.filter((signal) => signal && typeof signal.aborted === 'boolean');
|
|
1835
|
+
if (live.length <= 1) return live[0] || null;
|
|
1836
|
+
if (typeof AbortSignal.any === 'function') return AbortSignal.any(live);
|
|
1837
|
+
const controller = new AbortController();
|
|
1838
|
+
for (const signal of live) {
|
|
1839
|
+
if (signal.aborted) { controller.abort(); break; }
|
|
1840
|
+
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
1841
|
+
}
|
|
1842
|
+
return controller.signal;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/**
|
|
1846
|
+
* Open a session, retrying once without the relay's `ask_user` when the
|
|
1847
|
+
* runtime refuses the override (an older runtime). The refusal is
|
|
1848
|
+
* remembered until the runtime is stopped — the next runtime (possibly
|
|
1849
|
+
* auto-updated under the relay) gets another try. Only the override goes:
|
|
1850
|
+
* the relay's other tools (`remote_relay`) override nothing and stay.
|
|
1851
|
+
*/
|
|
1852
|
+
async function openSession(open, config) {
|
|
1853
|
+
try {
|
|
1854
|
+
return await open(config);
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
const tools = Array.isArray(config?.tools) ? config.tools : [];
|
|
1857
|
+
const isAskUserOverride = (tool) => tool?.name === RELAY_ASK_USER_TOOL_NAME && tool?.overridesBuiltInTool === true;
|
|
1858
|
+
if (!tools.some(isAskUserOverride) || !isToolOverrideRejection(error) || isSessionNotFoundError(error)) throw error;
|
|
1859
|
+
dbg('the runtime refused the relay ask_user override; using its built-in ask_user', error?.message || String(error));
|
|
1860
|
+
askUserOverrideRejected = true;
|
|
1861
|
+
const { tools: _tools, ...withoutTools } = config;
|
|
1862
|
+
const kept = tools.filter((tool) => !isAskUserOverride(tool));
|
|
1863
|
+
return open(kept.length ? { ...withoutTools, tools: kept } : withoutTools);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** The relay's own session tools; each is added only when it applies. */
|
|
1868
|
+
function relaySessionTools() {
|
|
1869
|
+
return [
|
|
1870
|
+
// The relay's `ask_user` (see copilot-ask-user-tool.mjs): the built-in's
|
|
1871
|
+
// fields plus `multi_select`, so a model can declare a multi-select
|
|
1872
|
+
// question instead of the relay guessing from its wording.
|
|
1873
|
+
...(relayAskUserTool && !askUserOverrideRejected ? [buildRelayAskUserTool()] : []),
|
|
1874
|
+
...(remoteRelayGate.isEnabled() ? [buildRemoteRelayTool()] : []),
|
|
1875
|
+
];
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function buildSessionConfig(model, reasoningEffort = null) {
|
|
883
1879
|
// Built once per session, not per request. Reads the mode off the LIVE turn
|
|
884
1880
|
// rather than closing over the one the session was built with, because one
|
|
885
1881
|
// session serves every turn and the user can switch modes between them.
|
|
1882
|
+
// With no turn live (a background agent's request) it is the mode of the
|
|
1883
|
+
// last delivered turn, as for a continuation.
|
|
886
1884
|
const decidePermission = createCopilotPermissionHandler({
|
|
887
1885
|
// Only the ask-mode branch actually blocks on a human; wrapping it keeps
|
|
888
|
-
// the stall watchdog off a turn where someone is deciding
|
|
1886
|
+
// the stall watchdog off a turn where someone is deciding, and only
|
|
1887
|
+
// there can an approval open a row of its own.
|
|
889
1888
|
bridge: {
|
|
890
|
-
askToolApproval: (permissionRequest, options) => whileAwaitingHuman(async () => {
|
|
1889
|
+
askToolApproval: (permissionRequest, options) => whileAwaitingHuman(async (turn) => {
|
|
891
1890
|
// An ask-mode approval raised the moment a continuation opens must
|
|
892
1891
|
// wait for the row its card will hang off — see `awaitInteractiveRow`.
|
|
893
|
-
await awaitInteractiveRow();
|
|
894
|
-
return questionBridge.askToolApproval(permissionRequest,
|
|
1892
|
+
await awaitInteractiveRow(turn);
|
|
1893
|
+
return questionBridge.askToolApproval(permissionRequest, {
|
|
1894
|
+
...options,
|
|
1895
|
+
signal: turn?.abortController?.signal || options?.signal || null,
|
|
1896
|
+
message: turn?.message,
|
|
1897
|
+
});
|
|
895
1898
|
}),
|
|
896
1899
|
},
|
|
897
|
-
getRelayMode: () => activeTurn?.message?.relayMode ||
|
|
1900
|
+
getRelayMode: () => activeTurn?.message?.relayMode || lastRelayMode,
|
|
898
1901
|
// An aborted turn must not leave the human staring at a card whose answer
|
|
899
1902
|
// nothing will read; the bridge times the card out instead.
|
|
900
1903
|
getSignal: () => activeTurn?.abortController?.signal || null,
|
|
@@ -911,6 +1914,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
911
1914
|
const provider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
|
|
912
1915
|
byokProvider = provider;
|
|
913
1916
|
const effort = normalizeRelayEffort(reasoningEffort);
|
|
1917
|
+
const tools = relaySessionTools();
|
|
914
1918
|
return {
|
|
915
1919
|
// The relay session id IS the SDK session id, so the runtime's own state
|
|
916
1920
|
// under ~/.copilot/session-state/<id> is addressable by conversation and
|
|
@@ -933,6 +1937,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
933
1937
|
// The runtime's own defaults, pinned. See DEFAULT_INFINITE_SESSION_CONFIG.
|
|
934
1938
|
...(infiniteSessionConfig ? { infiniteSessions: { ...infiniteSessionConfig } } : {}),
|
|
935
1939
|
onEvent: routeEvent,
|
|
1940
|
+
// The relay's own tools (`relaySessionTools`); omitted when there are none.
|
|
1941
|
+
...(tools.length ? { tools } : {}),
|
|
936
1942
|
// Permission policy follows the conversation's relay mode: agent and
|
|
937
1943
|
// autopilot auto-approve, plan denies non-read tools with feedback, and
|
|
938
1944
|
// ask asks the human through a relay question card. The handler reads the
|
|
@@ -956,12 +1962,13 @@ export function createCopilotSdkSessionRunner({
|
|
|
956
1962
|
// the deserializer is strict, so it is always a real boolean.
|
|
957
1963
|
onUserInputRequest: async (request) => {
|
|
958
1964
|
try {
|
|
959
|
-
const { answer, wasFreeform } = await whileAwaitingHuman(async () => {
|
|
1965
|
+
const { answer, wasFreeform } = await whileAwaitingHuman(async (turn) => {
|
|
960
1966
|
// Same gate as the approval path: an `ask_user` fired straight
|
|
961
1967
|
// after a continuation opened must get the row, not a null id.
|
|
962
|
-
await awaitInteractiveRow();
|
|
1968
|
+
await awaitInteractiveRow(turn);
|
|
963
1969
|
return questionBridge.askUserInput(request, {
|
|
964
|
-
signal:
|
|
1970
|
+
signal: turn?.abortController?.signal || null,
|
|
1971
|
+
message: turn?.message,
|
|
965
1972
|
});
|
|
966
1973
|
});
|
|
967
1974
|
return { answer, wasFreeform };
|
|
@@ -1015,12 +2022,12 @@ export function createCopilotSdkSessionRunner({
|
|
|
1015
2022
|
const source = String(request?.elicitationSource || '').trim();
|
|
1016
2023
|
const message = String(request?.message || '').trim()
|
|
1017
2024
|
|| 'Copilot needs structured input to continue this turn.';
|
|
1018
|
-
const result = await whileAwaitingHuman(async () => {
|
|
1019
|
-
await awaitInteractiveRow();
|
|
2025
|
+
const result = await whileAwaitingHuman(async (turn) => {
|
|
2026
|
+
await awaitInteractiveRow(turn);
|
|
1020
2027
|
return questionBridge.askStructured({
|
|
1021
2028
|
prompt: source ? `${message}\n\n(Requested by ${source}.)` : message,
|
|
1022
2029
|
requestedSchema: schema,
|
|
1023
|
-
}, { signal:
|
|
2030
|
+
}, { signal: turn?.abortController?.signal || null, message: turn?.message });
|
|
1024
2031
|
});
|
|
1025
2032
|
const content = result?.structuredAnswer;
|
|
1026
2033
|
if (result?.timedOut || !content || typeof content !== 'object' || Array.isArray(content)) {
|
|
@@ -1070,8 +2077,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
1070
2077
|
dbg('copilot runtime exited', detail);
|
|
1071
2078
|
const turn = activeTurn;
|
|
1072
2079
|
if (turn && !turn.settled) {
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
2080
|
+
// driveTurn tears the runtime down and publishes the failure record
|
|
2081
|
+
// onto every row the turn owns.
|
|
1075
2082
|
turn.fail(new Error(`the Copilot runtime exited before the turn completed (${detail})`));
|
|
1076
2083
|
return;
|
|
1077
2084
|
}
|
|
@@ -1126,7 +2133,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1126
2133
|
* rebuilt config. Nothing is lost either way: the relay session id IS the
|
|
1127
2134
|
* SDK session id, so the rebuild takes the ordinary resume path.
|
|
1128
2135
|
*/
|
|
1129
|
-
async function applySelection(model, effort
|
|
2136
|
+
async function applySelection(model, effort) {
|
|
1130
2137
|
if (!byokProvider) {
|
|
1131
2138
|
await modelSwitch.apply(session, { model, effort, byok: false });
|
|
1132
2139
|
return session;
|
|
@@ -1140,7 +2147,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1140
2147
|
} else {
|
|
1141
2148
|
dbg('BYOK ceilings differ for the target model; rebuilding the session', model);
|
|
1142
2149
|
}
|
|
1143
|
-
return rebuildByokSession(model, effort
|
|
2150
|
+
return rebuildByokSession(model, effort);
|
|
1144
2151
|
}
|
|
1145
2152
|
|
|
1146
2153
|
/**
|
|
@@ -1152,7 +2159,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1152
2159
|
* `SessionConfig.model`), so this path never re-enters the RPC attempt —
|
|
1153
2160
|
* which is also what makes it terminate: RPC refusal → rebuild → done.
|
|
1154
2161
|
*/
|
|
1155
|
-
async function rebuildByokSession(model, effort
|
|
2162
|
+
async function rebuildByokSession(model, effort) {
|
|
1156
2163
|
dbg('rebuilding the copilot session for a BYOK model/effort switch', model);
|
|
1157
2164
|
const closing = session;
|
|
1158
2165
|
session = null;
|
|
@@ -1162,13 +2169,16 @@ export function createCopilotSdkSessionRunner({
|
|
|
1162
2169
|
try { await closing?.disconnect?.(); } catch (error) {
|
|
1163
2170
|
dbg('session disconnect before model switch failed', error?.message || String(error));
|
|
1164
2171
|
}
|
|
1165
|
-
return ensureSession(model, effort
|
|
2172
|
+
return ensureSession(model, effort);
|
|
1166
2173
|
}
|
|
1167
2174
|
|
|
1168
|
-
async function ensureSession(model, effort
|
|
2175
|
+
async function ensureSession(model, effort) {
|
|
1169
2176
|
await ensureClient();
|
|
1170
2177
|
if (!session) {
|
|
1171
|
-
|
|
2178
|
+
// The tool list is part of the session: wait for the worker's first
|
|
2179
|
+
// registration answer (bounded, and it fails closed).
|
|
2180
|
+
await remoteRelayGate.ready();
|
|
2181
|
+
const config = buildSessionConfig(model, effort);
|
|
1172
2182
|
// Resume first, always. On a brand-new conversation this costs one
|
|
1173
2183
|
// failed RPC; on every other path (worker restart, idle shutdown,
|
|
1174
2184
|
// relay restart) it is the difference between continuing the
|
|
@@ -1177,7 +2187,7 @@ export function createCopilotSdkSessionRunner({
|
|
|
1177
2187
|
// exists and the NEXT reconnect must resume it.
|
|
1178
2188
|
let resumed = false;
|
|
1179
2189
|
try {
|
|
1180
|
-
session = await client.resumeSession(sdkSessionId, config);
|
|
2190
|
+
session = await openSession((cfg) => client.resumeSession(sdkSessionId, cfg), config);
|
|
1181
2191
|
resumed = true;
|
|
1182
2192
|
dbg(`resumed copilot session ${sdkSessionId.slice(0, 8)}`);
|
|
1183
2193
|
} catch (error) {
|
|
@@ -1191,9 +2201,13 @@ export function createCopilotSdkSessionRunner({
|
|
|
1191
2201
|
throw error;
|
|
1192
2202
|
}
|
|
1193
2203
|
dbg('copilot session state not found, creating', error?.message || String(error));
|
|
1194
|
-
session = await client.createSession(config);
|
|
2204
|
+
session = await openSession((cfg) => client.createSession(cfg), config);
|
|
1195
2205
|
dbg(`created copilot session ${sdkSessionId.slice(0, 8)}`);
|
|
1196
2206
|
}
|
|
2207
|
+
// Which experimental surfaces THIS runtime has (it auto-updates under
|
|
2208
|
+
// the relay): decides un-steer and the targeted Stop for the session.
|
|
2209
|
+
probeSessionRpc(session);
|
|
2210
|
+
scheduleQueueLaneRefresh();
|
|
1197
2211
|
if (resumed && !byokProvider) {
|
|
1198
2212
|
// A resumed HOSTED session keeps whatever model it was created with —
|
|
1199
2213
|
// `config.model` is not guaranteed to be honoured on resume — so the
|
|
@@ -1225,11 +2239,13 @@ export function createCopilotSdkSessionRunner({
|
|
|
1225
2239
|
publishModelSnapshot(resumed ? 'session-resume' : 'session-start');
|
|
1226
2240
|
return session;
|
|
1227
2241
|
}
|
|
1228
|
-
return applySelection(model, effort
|
|
2242
|
+
return applySelection(model, effort);
|
|
1229
2243
|
}
|
|
1230
2244
|
|
|
1231
2245
|
async function stopRuntime(reason) {
|
|
1232
2246
|
stopLifecycleTimer();
|
|
2247
|
+
// The next session is built from a fresh registration answer.
|
|
2248
|
+
void remoteRelayGate.refresh();
|
|
1233
2249
|
try { detachRuntimeExit(); } catch { /* the observer is best-effort */ }
|
|
1234
2250
|
detachRuntimeExit = () => {};
|
|
1235
2251
|
const closingSession = session;
|
|
@@ -1241,14 +2257,33 @@ export function createCopilotSdkSessionRunner({
|
|
|
1241
2257
|
// them: the tracked set is state about a process that no longer exists and
|
|
1242
2258
|
// must not pin the next one. The replay gate is reset for the same reason —
|
|
1243
2259
|
// the next connection resumes and arms its own window.
|
|
1244
|
-
if (backgroundShells.size()) {
|
|
2260
|
+
if (backgroundShells.size() || backgroundTasks.size) {
|
|
1245
2261
|
backgroundShells.reset();
|
|
1246
|
-
//
|
|
1247
|
-
|
|
2262
|
+
// Background agents and shells die with the runtime (live-probed: a
|
|
2263
|
+
// disconnect cancels the agents and drops the shells from the registry);
|
|
2264
|
+
// clear their panel cards too.
|
|
2265
|
+
backgroundTasks = new Map();
|
|
2266
|
+
agentDetails.clear();
|
|
2267
|
+
publishBackgroundTasks({ immediate: true });
|
|
2268
|
+
}
|
|
2269
|
+
if (taskRefreshTimer) {
|
|
2270
|
+
clearTimeout(taskRefreshTimer);
|
|
2271
|
+
taskRefreshTimer = null;
|
|
1248
2272
|
}
|
|
1249
2273
|
replayGate.reset();
|
|
2274
|
+
// A settled turn waiting on a card (driveTurn) would wait for an answer
|
|
2275
|
+
// nothing can take any more: the agent that asked died with the runtime.
|
|
2276
|
+
for (const turn of publishingTurns) turn.abortController.abort();
|
|
1250
2277
|
continuationDueSince = 0;
|
|
1251
2278
|
pendingActivities = [];
|
|
2279
|
+
compactingSince = 0;
|
|
2280
|
+
queuedLane = new Map();
|
|
2281
|
+
waitingSteers = 0;
|
|
2282
|
+
queuedItemCount = 0;
|
|
2283
|
+
steerSendsInFlight = 0;
|
|
2284
|
+
sessionRpc = { queue: false, interruptMainTurn: false, tasks: false };
|
|
2285
|
+
askUserOverrideRejected = false;
|
|
2286
|
+
syncDeliveryReadiness();
|
|
1252
2287
|
if (!closingSession && !closingClient) return;
|
|
1253
2288
|
dbg(`stopping the copilot runtime (${reason})`);
|
|
1254
2289
|
try { await closingSession?.disconnect?.(); } catch (error) {
|
|
@@ -1291,8 +2326,44 @@ export function createCopilotSdkSessionRunner({
|
|
|
1291
2326
|
dbg('continuation grace expired with no continuation turn');
|
|
1292
2327
|
continuationDueSince = 0;
|
|
1293
2328
|
}
|
|
2329
|
+
if (sessionRpc.tasks) {
|
|
2330
|
+
// The registry is the truth: live agents and shells hold the runtime up
|
|
2331
|
+
// to the cap (the relay's background-task slider, 0 = unlimited), and on
|
|
2332
|
+
// expiry they are CANCELLED — the runtime can stop them now — rather
|
|
2333
|
+
// than forgotten. Bounded: a task whose cancel the runtime refuses a
|
|
2334
|
+
// few times is abandoned instead of pinning the runtime forever. A
|
|
2335
|
+
// re-read once per lifecycle poll catches state the events miss (a
|
|
2336
|
+
// shell that exited after a cancel emits no notification).
|
|
2337
|
+
const capMs = Number(getBackgroundTaskTimeoutMs()) || 0;
|
|
2338
|
+
const live = runningBackgroundTasks();
|
|
2339
|
+
if (!live.length) return false;
|
|
2340
|
+
if (capMs > 0) {
|
|
2341
|
+
for (const task of live) {
|
|
2342
|
+
const startedAt = Date.parse(String(task.startedAt || '')) || agentDetails.get(task.id)?.startedAt || 0;
|
|
2343
|
+
if (!startedAt || now - startedAt < capMs) continue;
|
|
2344
|
+
const refusals = taskCancelRefusals.get(task.id) || 0;
|
|
2345
|
+
if (refusals >= MAX_TASK_CANCEL_REFUSALS) {
|
|
2346
|
+
dbg('background task cap reached and the runtime would not cancel it; abandoning it', task.id);
|
|
2347
|
+
abandonedTasks.add(task.id);
|
|
2348
|
+
backgroundTasks.delete(task.id);
|
|
2349
|
+
publishBackgroundTasks({ immediate: true });
|
|
2350
|
+
continue;
|
|
2351
|
+
}
|
|
2352
|
+
dbg('background task cap reached; cancelling it', task.id, task.description || task.command || '');
|
|
2353
|
+
void stopBackgroundTask(task.id).then((cancelled) => {
|
|
2354
|
+
if (!cancelled) taskCancelRefusals.set(task.id, (taskCancelRefusals.get(task.id) || 0) + 1);
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
if (!runningBackgroundTasks().length) return false;
|
|
2358
|
+
}
|
|
2359
|
+
if (now - lastTaskRefreshAt >= lifecyclePollMs) scheduleTaskRefresh();
|
|
2360
|
+
return true;
|
|
2361
|
+
}
|
|
1294
2362
|
if (!backgroundShells.size()) return false;
|
|
1295
|
-
|
|
2363
|
+
// Without the registry there is no Stop and no card worth the name, so an
|
|
2364
|
+
// unlimited slider (0) must not mean "a forgotten `sleep 99999` pins the
|
|
2365
|
+
// runtime forever": the old 30-minute default applies on this path.
|
|
2366
|
+
const capMs = Number(getBackgroundTaskTimeoutMs()) || DEFAULT_BACKGROUND_TASK_TIMEOUT_MS;
|
|
1296
2367
|
let expiredAny = false;
|
|
1297
2368
|
for (const shell of backgroundShells.expireOlderThan(capMs)) {
|
|
1298
2369
|
expiredAny = true;
|
|
@@ -1310,7 +2381,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
1310
2381
|
// A pending question card means a human is mid-answer; tearing the runtime
|
|
1311
2382
|
// down under them would discard the session the answer belongs to. A turn
|
|
1312
2383
|
// that is still publishing counts too: it settled, but its rows are live.
|
|
1313
|
-
if (
|
|
2384
|
+
if (
|
|
2385
|
+
disposed || activeTurn || publishingTurns.size > 0 || pendingHumanRequests > 0 || pendingRelayToolCalls > 0 || !client
|
|
2386
|
+
) return;
|
|
1314
2387
|
if (!(idleShutdownMs > 0)) return;
|
|
1315
2388
|
// Evaluated before the idle clock so the caps still expire on a runtime
|
|
1316
2389
|
// that has been quiet far longer than the idle window.
|
|
@@ -1339,18 +2412,81 @@ export function createCopilotSdkSessionRunner({
|
|
|
1339
2412
|
return String(message?.providerModel || '').trim() || defaultModel;
|
|
1340
2413
|
}
|
|
1341
2414
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
2415
|
+
/**
|
|
2416
|
+
* A queue row this turn owes an answer.
|
|
2417
|
+
*
|
|
2418
|
+
* `role`: 'primary' (the delivered row that opened the turn), 'steer' (a row
|
|
2419
|
+
* delivered mid-turn and pushed into the same runtime interaction) or
|
|
2420
|
+
* 'self' (the synthetic row of a self-initiated turn). An entry is owned —
|
|
2421
|
+
* heartbeat-claimed, crash-guard-requeued — from creation until `released`.
|
|
2422
|
+
*/
|
|
2423
|
+
function createEntry(turn, message, role) {
|
|
2424
|
+
const entry = {
|
|
2425
|
+
turn,
|
|
2426
|
+
message,
|
|
2427
|
+
role,
|
|
2428
|
+
// The id `send()` resolved; the key `user.message.messageId` matches.
|
|
2429
|
+
runtimeId: null,
|
|
2430
|
+
// A root `user.message` named this entry: the runtime has taken the
|
|
2431
|
+
// prompt. `delivery` is the runtime's own word for how.
|
|
2432
|
+
consumed: false,
|
|
2433
|
+
delivery: null,
|
|
2434
|
+
interactionId: null,
|
|
2435
|
+
// The entry whose run absorbed this one (`delivery:"steering"`).
|
|
2436
|
+
foldedInto: null,
|
|
2437
|
+
consumedMarked: false,
|
|
2438
|
+
// Its outcome was decided and its publishes started / it landed.
|
|
2439
|
+
settled: false,
|
|
2440
|
+
released: false,
|
|
2441
|
+
cancelled: false,
|
|
2442
|
+
// Something worth keeping was published on this row: the run's own
|
|
2443
|
+
// work (a tool line, a thought, a lane), or a carried-in line that
|
|
2444
|
+
// reports a failure — not bookkeeping, and not the other lines carried
|
|
2445
|
+
// in from between turns (`showsWork`). Decides whether an empty
|
|
2446
|
+
// continuation may vanish.
|
|
2447
|
+
showedWork: false,
|
|
2448
|
+
// `lastStreamedText` is what this row last received on /api/stream —
|
|
2449
|
+
// the stream gate and the abort/error fallback text both read it.
|
|
2450
|
+
state: { lastStreamedText: '' },
|
|
2451
|
+
};
|
|
2452
|
+
entry.done = new Promise((resolve) => { entry.resolveDone = resolve; });
|
|
2453
|
+
turn.entries.push(entry);
|
|
2454
|
+
return entry;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
function assignRuntimeId(turn, entry, runtimeId) {
|
|
2458
|
+
const id = String(runtimeId || '').trim();
|
|
2459
|
+
entry.runtimeId = id || null;
|
|
2460
|
+
if (!id) return;
|
|
2461
|
+
turn.pushed.set(id, entry);
|
|
2462
|
+
// The runtime can start work on a prompt before the send() that pushed it
|
|
2463
|
+
// has resolved with its id. The boundary was remembered; reconcile it now,
|
|
2464
|
+
// on the dispatch chain so it orders after the events already captured.
|
|
2465
|
+
const unclaimed = turn.unclaimedUserMessages.get(id);
|
|
2466
|
+
if (!unclaimed) return;
|
|
2467
|
+
turn.unclaimedUserMessages.delete(id);
|
|
2468
|
+
dispatchChain = dispatchChain
|
|
2469
|
+
.then(() => adoptSegment(turn, entry, unclaimed))
|
|
2470
|
+
.catch((error) => { dbg('late prompt-boundary reconcile failed', error?.message || String(error)); });
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
function createTurn(message, { kind = 'delivered', adoptEntry = null } = {}) {
|
|
2474
|
+
const continuation = kind === 'continuation';
|
|
2475
|
+
const turn = {
|
|
2476
|
+
// 'delivered' (a queue row arrived) | 'continuation' (the runtime started
|
|
2477
|
+
// work by itself and this worker minted a synthetic row for it).
|
|
2478
|
+
kind,
|
|
2479
|
+
message,
|
|
2480
|
+
model: '',
|
|
2481
|
+
// The runner's cross-turn knowledge of background agents rides in, so a
|
|
2482
|
+
// later turn's normalizer opens no lane for an agent spawned earlier.
|
|
2483
|
+
normalizer: createNormalizerImpl({ isBackgroundAgent: (agentId) => agentDetails.has(agentId) }),
|
|
1351
2484
|
result: null,
|
|
2485
|
+
failure: null,
|
|
1352
2486
|
settled: false,
|
|
1353
2487
|
aborted: false,
|
|
2488
|
+
// The row the user pressed Stop on (from the abort control), when known.
|
|
2489
|
+
abortedRowId: null,
|
|
1354
2490
|
stallTimer: null,
|
|
1355
2491
|
planBoardPosted: false,
|
|
1356
2492
|
// Set when a mutating tool was actually approved and run this turn.
|
|
@@ -1365,45 +2501,61 @@ export function createCopilotSdkSessionRunner({
|
|
|
1365
2501
|
// rather than failing the worker.
|
|
1366
2502
|
discarded: false,
|
|
1367
2503
|
controlState: null,
|
|
1368
|
-
// How many prompts have been SENT into this interaction. The runtime
|
|
1369
|
-
// consumes queued prompts in order, and opens one `user.message` segment
|
|
1370
|
-
// per prompt as it picks each up, so send order IS segment order.
|
|
1371
|
-
//
|
|
1372
|
-
// Deliberately not derived from the normalizer's live segment count: at
|
|
1373
|
-
// the moment a steering send happens the runtime may not have opened the
|
|
1374
|
-
// previous prompt's segment yet, and the steered row would then be
|
|
1375
|
-
// attributed the PREVIOUS prompt's answer.
|
|
1376
|
-
//
|
|
1377
|
-
// Starts at 1, not 0: this turn's own prompt is always its first, and
|
|
1378
|
-
// counting it here rather than after `send()` resolves closes the race
|
|
1379
|
-
// where a steering delivery lands between the two. A continuation sent no
|
|
1380
|
-
// prompt at all, so it starts at 0.
|
|
1381
|
-
promptsSent: continuation ? 0 : 1,
|
|
1382
|
-
// The segment this turn's OWN reply lands in. For a delivered turn that
|
|
1383
|
-
// is where its prompt is picked up — non-zero when a previous interaction
|
|
1384
|
-
// left prompts queued inside the runtime, which are consumed first. A
|
|
1385
|
-
// continuation has no `user.message` of its own, so the normalizer's
|
|
1386
|
-
// implicit first segment is its own.
|
|
1387
|
-
baseSegment: continuation ? 0 : carriedPrompts,
|
|
1388
|
-
// The segment the NEXT prompt steered into this interaction will be
|
|
1389
|
-
// answered in. Tracked explicitly rather than derived, because the two
|
|
1390
|
-
// kinds differ: a delivered turn's own prompt occupies `baseSegment`, so
|
|
1391
|
-
// the next is `baseSegment + 1`; a continuation occupies segment 0
|
|
1392
|
-
// without having sent anything, so the first steered prompt opens
|
|
1393
|
-
// segment 1. Getting this wrong cross-publishes the continuation's reply
|
|
1394
|
-
// into the user's row.
|
|
1395
|
-
nextSegmentIndex: continuation ? 1 : carriedPrompts + 1,
|
|
1396
|
-
// The lowest segment index any prompt SENT by this turn can occupy — the
|
|
1397
|
-
// floor for the carried-prompt arithmetic when the interaction ends.
|
|
1398
|
-
firstSentSegment: continuation ? 1 : carriedPrompts,
|
|
1399
2504
|
// Cancels any relay question card this turn is blocked on, so an aborted
|
|
1400
2505
|
// turn does not leave a human answering into the void.
|
|
1401
2506
|
abortController: new AbortController(),
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
//
|
|
1405
|
-
|
|
2507
|
+
// Cancels the `remote_relay` calls that started under this turn — on the
|
|
2508
|
+
// user's Stop only. Not `abortController`: that one also fires when the
|
|
2509
|
+
// turn settles normally, and a background agent's call must outlive that.
|
|
2510
|
+
remoteRelayStop: new AbortController(),
|
|
2511
|
+
// Cards (questions, ask-mode approvals, elicitations) open on this
|
|
2512
|
+
// turn's row; `humansIdle` resolves when the last one is done. A turn
|
|
2513
|
+
// that settles with one still open publishes only after it (driveTurn).
|
|
2514
|
+
humanRequests: 0,
|
|
2515
|
+
humansIdle: null,
|
|
2516
|
+
// Whether any card was ever raised on this turn's row.
|
|
2517
|
+
askedHuman: false,
|
|
2518
|
+
// Opened by `ensureInteractiveTurn` for a question rather than by the
|
|
2519
|
+
// runtime, and whether root work has joined it since.
|
|
2520
|
+
humanOrigin: false,
|
|
2521
|
+
rootWork: false,
|
|
2522
|
+
// Every row this turn owes an answer, in push order.
|
|
2523
|
+
entries: [],
|
|
2524
|
+
// runtime message id → entry, for the `user.message` acknowledgement.
|
|
2525
|
+
pushed: new Map(),
|
|
2526
|
+
// Normalizer segment index → the entry whose row the segment's text
|
|
2527
|
+
// belongs to. A `delivery:"steering"` prompt's segment belongs to the
|
|
2528
|
+
// run it was folded into; a `queued`/`idle` one starts its own run.
|
|
2529
|
+
segmentOwners: [],
|
|
2530
|
+
// The entry whose run the runtime is working on right now.
|
|
2531
|
+
currentOwner: null,
|
|
2532
|
+
primaryEntry: null,
|
|
2533
|
+
// Early settles (a previous run's row, published when the next run
|
|
2534
|
+
// opened) run off the event dispatch chain; driveTurn awaits them
|
|
2535
|
+
// before releasing ownership.
|
|
2536
|
+
settlePromises: [],
|
|
2537
|
+
// False until the turn-opening send() has resolved: the delivery hold.
|
|
2538
|
+
primarySent: continuation,
|
|
2539
|
+
// Root `user.message`s whose id no pushed entry knew yet (the event beat
|
|
2540
|
+
// the send() resolution): runtime id → boundary, reconciled by
|
|
2541
|
+
// assignRuntimeId.
|
|
2542
|
+
unclaimedUserMessages: new Map(),
|
|
1406
2543
|
};
|
|
2544
|
+
if (adoptEntry) {
|
|
2545
|
+
// A pushed row whose run the runtime opened after the turn it was pushed
|
|
2546
|
+
// into had already settled: it becomes the primary of a turn of its own.
|
|
2547
|
+
adoptEntry.turn = turn;
|
|
2548
|
+
turn.entries.push(adoptEntry);
|
|
2549
|
+
turn.primaryEntry = adoptEntry;
|
|
2550
|
+
if (adoptEntry.runtimeId) turn.pushed.set(adoptEntry.runtimeId, adoptEntry);
|
|
2551
|
+
turn.primarySent = true;
|
|
2552
|
+
} else {
|
|
2553
|
+
turn.primaryEntry = createEntry(turn, message, continuation ? 'self' : 'primary');
|
|
2554
|
+
}
|
|
2555
|
+
turn.currentOwner = turn.primaryEntry;
|
|
2556
|
+
if (continuation) turn.segmentOwners[0] = turn.primaryEntry;
|
|
2557
|
+
turn.primaryReady = new Promise((resolve) => { turn.resolvePrimaryReady = resolve; });
|
|
2558
|
+
if (turn.primarySent) turn.resolvePrimaryReady();
|
|
1407
2559
|
if (continuation) {
|
|
1408
2560
|
// The single in-flight registration for this turn's synthetic row, and
|
|
1409
2561
|
// the signal that abandons it. One promise, stored ON the turn, so the
|
|
@@ -1425,18 +2577,19 @@ export function createCopilotSdkSessionRunner({
|
|
|
1425
2577
|
turn.rejectDone = reject;
|
|
1426
2578
|
});
|
|
1427
2579
|
// The stall watchdog and the runtime-exit observer can reject `turn.done`
|
|
1428
|
-
// before
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
// still sees the rejection.
|
|
2580
|
+
// before anything awaits it — an unhandled rejection that the worker crash
|
|
2581
|
+
// guard would escalate into a whole-worker failure. This handler exists
|
|
2582
|
+
// only to mark the promise as handled; driveTurn still sees the rejection.
|
|
1432
2583
|
turn.done.catch(() => {});
|
|
1433
2584
|
turn.settle = () => {
|
|
1434
2585
|
if (turn.settled) return;
|
|
1435
2586
|
turn.settled = true;
|
|
1436
2587
|
turn.disarmStall();
|
|
1437
2588
|
// The terminal event releases the event stream at once — see
|
|
1438
|
-
// `beginPublishing`. The queue
|
|
2589
|
+
// `beginPublishing`. The queue rows stay owned until the publishes land.
|
|
1439
2590
|
beginPublishing(turn);
|
|
2591
|
+
turn.resolvePrimaryReady();
|
|
2592
|
+
syncDeliveryReadiness();
|
|
1440
2593
|
turn.resolveDone();
|
|
1441
2594
|
};
|
|
1442
2595
|
turn.fail = (error) => {
|
|
@@ -1444,6 +2597,8 @@ export function createCopilotSdkSessionRunner({
|
|
|
1444
2597
|
turn.settled = true;
|
|
1445
2598
|
turn.disarmStall();
|
|
1446
2599
|
beginPublishing(turn);
|
|
2600
|
+
turn.resolvePrimaryReady();
|
|
2601
|
+
syncDeliveryReadiness();
|
|
1447
2602
|
turn.rejectDone(error);
|
|
1448
2603
|
};
|
|
1449
2604
|
turn.disarmStall = () => {
|
|
@@ -1458,7 +2613,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
1458
2613
|
// A human staring at a question card is not a stalled runtime. Re-arm
|
|
1459
2614
|
// rather than fail: the card has its own (much longer) timeout, and
|
|
1460
2615
|
// failing the row here would settle it while the answer is still coming.
|
|
1461
|
-
|
|
2616
|
+
// A remote_relay call waiting on another relay is the same kind of
|
|
2617
|
+
// silence; the relay bounds that wait itself.
|
|
2618
|
+
if (pendingHumanRequests > 0 || pendingRelayToolCalls > 0) {
|
|
1462
2619
|
turn.armStall();
|
|
1463
2620
|
return;
|
|
1464
2621
|
}
|
|
@@ -1472,44 +2629,517 @@ export function createCopilotSdkSessionRunner({
|
|
|
1472
2629
|
return turn;
|
|
1473
2630
|
}
|
|
1474
2631
|
|
|
2632
|
+
// ----------------------------------------------------- prompt boundaries --
|
|
2633
|
+
|
|
2634
|
+
function claimSegment(turn, index, owner) {
|
|
2635
|
+
// Leading unowned segments (text the runtime produced before any prompt
|
|
2636
|
+
// boundary, e.g. the answer to a prompt carried over from a previous
|
|
2637
|
+
// interaction) go to the first owner: that is where the merged note sent
|
|
2638
|
+
// the reader.
|
|
2639
|
+
for (let j = 0; j <= index; j += 1) {
|
|
2640
|
+
if (!turn.segmentOwners[j]) turn.segmentOwners[j] = owner;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
/**
|
|
2645
|
+
* A root `user.message` arrived: the runtime started work on a prompt. Called
|
|
2646
|
+
* on the dispatch chain right after the normalizer opened the prompt's
|
|
2647
|
+
* segment, so `promptCount() - 1` is that segment.
|
|
2648
|
+
*/
|
|
2649
|
+
async function noteUserMessage(turn, event) {
|
|
2650
|
+
const data = event?.data && typeof event.data === 'object' ? event.data : {};
|
|
2651
|
+
const index = Math.max(0, turn.normalizer.promptCount() - 1);
|
|
2652
|
+
const runtimeId = String(data.messageId || '').trim();
|
|
2653
|
+
const boundary = {
|
|
2654
|
+
index,
|
|
2655
|
+
delivery: String(data.delivery || '').trim().toLowerCase(),
|
|
2656
|
+
interactionId: String(data.interactionId || '').trim(),
|
|
2657
|
+
};
|
|
2658
|
+
const entry = runtimeId ? turn.pushed.get(runtimeId) : null;
|
|
2659
|
+
if (entry) {
|
|
2660
|
+
await adoptSegment(turn, entry, boundary);
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
// Not one of ours (yet): a prompt the runtime injected itself (an autopilot
|
|
2664
|
+
// nudge, a carried-over prompt from a previous interaction), or a pushed
|
|
2665
|
+
// prompt whose send() has not resolved with its id. Its text belongs to
|
|
2666
|
+
// the run in progress until proven otherwise.
|
|
2667
|
+
if (runtimeId && !turn.unclaimedUserMessages.has(runtimeId)) turn.unclaimedUserMessages.set(runtimeId, boundary);
|
|
2668
|
+
claimSegment(turn, index, turn.currentOwner || turn.primaryEntry);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
/**
|
|
2672
|
+
* Bind a prompt boundary to the entry it names.
|
|
2673
|
+
*
|
|
2674
|
+
* `delivery:"steering"`: the runtime folded the prompt into the run already
|
|
2675
|
+
* in progress — the reply that follows belongs to that run's row, and this
|
|
2676
|
+
* row will settle with the fold marker (Claude's `folded`). The relay is
|
|
2677
|
+
* told the prompt is consumed at once, so a crash between now and the
|
|
2678
|
+
* marker fails the row instead of re-running it.
|
|
2679
|
+
*
|
|
2680
|
+
* Anything else (`queued`, `idle`, unknown): the prompt starts its own run
|
|
2681
|
+
* on the same interaction. The previous run is over — nothing more will be
|
|
2682
|
+
* produced for its row, so it is settled right away rather than at the
|
|
2683
|
+
* drain's end, exactly as a Claude context finalizes when the CLI opens the
|
|
2684
|
+
* next one.
|
|
2685
|
+
*/
|
|
2686
|
+
async function adoptSegment(turn, entry, { index, delivery, interactionId }) {
|
|
2687
|
+
entry.consumed = true;
|
|
2688
|
+
noteCancellableChange();
|
|
2689
|
+
entry.delivery = delivery || null;
|
|
2690
|
+
entry.interactionId = interactionId || null;
|
|
2691
|
+
const running = turn.currentOwner || turn.primaryEntry;
|
|
2692
|
+
if (delivery === 'steering' && running && running !== entry && !running.settled) {
|
|
2693
|
+
entry.foldedInto = running;
|
|
2694
|
+
turn.segmentOwners[index] = running;
|
|
2695
|
+
claimSegment(turn, index, running);
|
|
2696
|
+
if (entry.role === 'steer' && !entry.consumedMarked) {
|
|
2697
|
+
entry.consumedMarked = true;
|
|
2698
|
+
void markConsumed(entry.message.conversationId, [entry.message]);
|
|
2699
|
+
}
|
|
2700
|
+
dbg('steered prompt folded into the running turn', entry.message.id, `→ ${running.message.id || '(continuation)'}`);
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
turn.segmentOwners[index] = entry;
|
|
2704
|
+
claimSegment(turn, index, entry);
|
|
2705
|
+
if (running === entry) return;
|
|
2706
|
+
turn.currentOwner = entry;
|
|
2707
|
+
if (running && !running.settled) {
|
|
2708
|
+
dbg('pushed prompt runs as its own turn; settling the previous run', entry.message.id, `after ${running.message.id || '(continuation)'}`);
|
|
2709
|
+
// Off the dispatch chain: a slow relay must not stall the events of
|
|
2710
|
+
// the run that just opened (or its terminator) behind this publish.
|
|
2711
|
+
// A continuation whose row is not minted yet (a steer pushed past the
|
|
2712
|
+
// hold after its wait ran out) settles once the row exists — publishing
|
|
2713
|
+
// now would name no row — or is discarded with it.
|
|
2714
|
+
const settled = running.message?.id || !turn.rowReady
|
|
2715
|
+
? settleEntry(turn, running, { type: 'completed' })
|
|
2716
|
+
: turn.rowReady.then((ready) => settleEntry(turn, running, { type: ready ? 'completed' : 'discarded' }));
|
|
2717
|
+
turn.settlePromises.push(settled);
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
// -------------------------------------------------------------- settling --
|
|
2722
|
+
|
|
2723
|
+
/**
|
|
2724
|
+
* Whether a completed row with no text has nothing to show at all: the
|
|
2725
|
+
* runtime opened it by itself (typically right after a background agent
|
|
2726
|
+
* finished), its run published no tool line, thought or lane, no card was
|
|
2727
|
+
* raised on it, no user message was folded into it, and nothing carried in
|
|
2728
|
+
* reported a failure. Such a row would only ever read "the turn completed
|
|
2729
|
+
* without a text reply", and the task card already showed the agent
|
|
2730
|
+
* finishing (live, gpt-5.6-luna, 2026-09-26). A failed agent's card is gone
|
|
2731
|
+
* by then, so the row carrying its failure line is kept (`showsWork`).
|
|
2732
|
+
* Every delivered row keeps the note — somebody asked something there.
|
|
2733
|
+
*/
|
|
2734
|
+
function settlesSilently(turn, entry, folded) {
|
|
2735
|
+
return turn.kind === 'continuation'
|
|
2736
|
+
&& entry.role === 'self'
|
|
2737
|
+
&& !turn.humanOrigin
|
|
2738
|
+
&& !turn.askedHuman
|
|
2739
|
+
&& !turn.planBoardPosted
|
|
2740
|
+
&& !entry.showedWork
|
|
2741
|
+
&& !folded.length
|
|
2742
|
+
&& Boolean(entry.message?.id);
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
/**
|
|
2746
|
+
* Settle a silent continuation by tearing its row down: the requeue route's
|
|
2747
|
+
* continuation branch drops a processing continuation without an answer
|
|
2748
|
+
* (`dropped: 'continuation'`), the same teardown a dead worker's rows get.
|
|
2749
|
+
* A lost request leaves the row to that same stale-continuation recovery.
|
|
2750
|
+
*/
|
|
2751
|
+
async function dropSilentContinuation(entry) {
|
|
2752
|
+
dbg('continuation ended with nothing to show; dropping its row', entry.message.id);
|
|
2753
|
+
await api('POST', '/api/requeue', { messageId: entry.message.id, ...attemptFields(entry.message) })
|
|
2754
|
+
.catch((error) => { dbg('silent continuation drop failed', entry.message.id, error?.message || String(error)); });
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
/**
|
|
2758
|
+
* Decide and publish one row's outcome. Idempotent per entry. A run owner's
|
|
2759
|
+
* settle also settles the steers folded into its run (they were answered by
|
|
2760
|
+
* its reply), in push order, after the reply itself.
|
|
2761
|
+
*/
|
|
2762
|
+
async function settleEntry(turn, entry, outcome) {
|
|
2763
|
+
if (entry.settled) return;
|
|
2764
|
+
entry.settled = true;
|
|
2765
|
+
const model = turn.result?.model || turn.normalizer.model || turn.model || null;
|
|
2766
|
+
const folded = turn.entries.filter((other) => other !== entry && !other.settled && other.foldedInto === entry);
|
|
2767
|
+
try {
|
|
2768
|
+
switch (outcome.type) {
|
|
2769
|
+
case 'completed': {
|
|
2770
|
+
const text = finalTextFor(turn, entry, turn.result);
|
|
2771
|
+
if (!text && settlesSilently(turn, entry, folded)) {
|
|
2772
|
+
await dropSilentContinuation(entry);
|
|
2773
|
+
break;
|
|
2774
|
+
}
|
|
2775
|
+
const published = text || EMPTY_TURN_COMPLETION_NOTE;
|
|
2776
|
+
if (entry.role !== 'steer' && shouldPostPlanBoard({
|
|
2777
|
+
relayMode: entry.message.relayMode,
|
|
2778
|
+
finalText: text,
|
|
2779
|
+
alreadyPosted: turn.planBoardPosted,
|
|
2780
|
+
acted: turn.acted === true,
|
|
2781
|
+
})) {
|
|
2782
|
+
await publishPlanBoard(turn, text, 'plan-mode-fallback');
|
|
2783
|
+
}
|
|
2784
|
+
await publishFinalStream(entry.message, published);
|
|
2785
|
+
await publishResponse(entry.message, {
|
|
2786
|
+
text: published,
|
|
2787
|
+
model,
|
|
2788
|
+
consumedSteerIds: folded.map((other) => other.message),
|
|
2789
|
+
});
|
|
2790
|
+
break;
|
|
2791
|
+
}
|
|
2792
|
+
case 'aborted-by-user': {
|
|
2793
|
+
// The queue row's fate belongs to the server-side abort control,
|
|
2794
|
+
// exactly as in the Claude and Cursor workers. Publishing a response
|
|
2795
|
+
// here would double-settle the row.
|
|
2796
|
+
await publishFinalStream(entry.message, finalTextFor(turn, entry, turn.result));
|
|
2797
|
+
break;
|
|
2798
|
+
}
|
|
2799
|
+
case 'user-stopped': {
|
|
2800
|
+
// The run a Stop cut short while naming another row. The abort ack
|
|
2801
|
+
// fails only the row it names, so this one is failed here — as
|
|
2802
|
+
// stopped, never requeued: a requeue would re-run a prompt the user
|
|
2803
|
+
// stopped (the Claude worker's lingering-stopped rule). Claimed
|
|
2804
|
+
// first, so a crash meanwhile fails it rather than re-running it.
|
|
2805
|
+
const id = String(entry.message.id || '');
|
|
2806
|
+
const terminalError = buildRelayStopFailure();
|
|
2807
|
+
claimSettling(entry.message, 'stopped');
|
|
2808
|
+
try {
|
|
2809
|
+
await publishFinalStream(entry.message, finalTextFor(turn, entry, turn.result));
|
|
2810
|
+
const outcome = await publishResponse(entry.message, {
|
|
2811
|
+
text: terminalError.message,
|
|
2812
|
+
model,
|
|
2813
|
+
terminalError,
|
|
2814
|
+
requeueOnFailure: false,
|
|
2815
|
+
consumedSteerIds: folded.map((other) => other.message),
|
|
2816
|
+
});
|
|
2817
|
+
// Not even the stop landed: the heartbeat hands it to the relay to fail.
|
|
2818
|
+
if (outcome === 'failed' && id) settleFailedTerminals.set(id, terminalError);
|
|
2819
|
+
} finally {
|
|
2820
|
+
releaseSettling(id);
|
|
2821
|
+
}
|
|
2822
|
+
break;
|
|
2823
|
+
}
|
|
2824
|
+
case 'runtime-interrupted': {
|
|
2825
|
+
const own = finalTextFor(turn, entry, turn.result);
|
|
2826
|
+
const text = own ? `${own}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
|
|
2827
|
+
await publishFinalStream(entry.message, text);
|
|
2828
|
+
await publishResponse(entry.message, { text, model, consumedSteerIds: folded.map((other) => other.message) });
|
|
2829
|
+
break;
|
|
2830
|
+
}
|
|
2831
|
+
case 'error': {
|
|
2832
|
+
const { classified } = outcome;
|
|
2833
|
+
await publishFinalStream(entry.message, entry.state.lastStreamedText);
|
|
2834
|
+
await publishResponse(entry.message, {
|
|
2835
|
+
text: classified.text,
|
|
2836
|
+
model,
|
|
2837
|
+
terminalError: terminalErrorRecord(entry.message, classified),
|
|
2838
|
+
consumedSteerIds: folded.map((other) => other.message),
|
|
2839
|
+
});
|
|
2840
|
+
break;
|
|
2841
|
+
}
|
|
2842
|
+
case 'folded':
|
|
2843
|
+
await publishSettleMarker(entry.message, { text: STEER_FOLDED_TEXT, model, kind: 'folded', variant: 'folded' });
|
|
2844
|
+
break;
|
|
2845
|
+
case 'stopped':
|
|
2846
|
+
await publishSettleMarker(entry.message, { text: STEER_STOPPED_TEXT, model, kind: 'stopped', variant: 'stopped' });
|
|
2847
|
+
break;
|
|
2848
|
+
case 'merged-note':
|
|
2849
|
+
// Accepted by the runtime, never started: still queued inside it, so
|
|
2850
|
+
// it will be answered at the start of the next interaction — a
|
|
2851
|
+
// requeue would run it twice.
|
|
2852
|
+
await publishFinalStream(entry.message, STEERED_ROW_MERGED_NOTE);
|
|
2853
|
+
await publishResponse(entry.message, { text: STEERED_ROW_MERGED_NOTE, model });
|
|
2854
|
+
break;
|
|
2855
|
+
case 'cancelled':
|
|
2856
|
+
case 'discarded':
|
|
2857
|
+
default:
|
|
2858
|
+
break;
|
|
2859
|
+
}
|
|
2860
|
+
} catch (error) {
|
|
2861
|
+
dbg('settle publish failed', entry.message?.id || '(no id)', outcome.type, error?.message || String(error));
|
|
2862
|
+
} finally {
|
|
2863
|
+
entry.released = true;
|
|
2864
|
+
entry.resolveDone(true);
|
|
2865
|
+
}
|
|
2866
|
+
for (const other of folded) {
|
|
2867
|
+
const foldOutcome = outcome.type === 'completed' || outcome.type === 'merged-note'
|
|
2868
|
+
? { type: 'folded' }
|
|
2869
|
+
: outcome.type === 'error' ? outcome : { type: 'stopped' };
|
|
2870
|
+
await settleEntry(turn, other, foldOutcome);
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
/** Remove our unconsumed prompts from the runtime's queued lane (best effort). */
|
|
2875
|
+
async function clearOwnedQueuedItems(entries) {
|
|
2876
|
+
if (!entries.length || !session || !sessionRpc.queue) return;
|
|
2877
|
+
await refreshQueueLane();
|
|
2878
|
+
for (const entry of entries) {
|
|
2879
|
+
const itemId = queuedLane.get(entry.runtimeId);
|
|
2880
|
+
if (!itemId) continue;
|
|
2881
|
+
try {
|
|
2882
|
+
await session.rpc.queue.removeAt({ id: itemId });
|
|
2883
|
+
dbg('removed a stopped prompt from the runtime queue', entry.message.id);
|
|
2884
|
+
} catch (error) {
|
|
2885
|
+
dbg('queue.removeAt after Stop failed', entry.message.id, error?.message || String(error));
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
/**
|
|
2891
|
+
* Hold pushed-but-unstarted prompts for the orphan grace, then return the
|
|
2892
|
+
* ones the runtime still did not open (an opened one was adopted meanwhile
|
|
2893
|
+
* and belongs to a turn of its own now).
|
|
2894
|
+
*/
|
|
2895
|
+
async function settleAfterOrphanGrace(turn, orphans) {
|
|
2896
|
+
if (!orphans.length) return [];
|
|
2897
|
+
if (orphanGraceMs > 0) {
|
|
2898
|
+
dbg('pushed prompts not started at idle; waiting for the runtime to open them', orphans.map((entry) => entry.message.id).join(','));
|
|
2899
|
+
await sleep(orphanGraceMs);
|
|
2900
|
+
}
|
|
2901
|
+
return orphans.filter((entry) => entry.turn === turn && !entry.settled);
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
/**
|
|
2905
|
+
* Publish everything a finished turn still owes, per row.
|
|
2906
|
+
*
|
|
2907
|
+
* Shared by both turn kinds: the runtime does not distinguish a turn it
|
|
2908
|
+
* started from one it was asked for, so neither does the reporting.
|
|
2909
|
+
*/
|
|
2910
|
+
async function finishTurn(turn) {
|
|
2911
|
+
const result = turn.result;
|
|
2912
|
+
const failure = turn.failure;
|
|
2913
|
+
// Capture only. The POST fires from driveTurn's `finally`, after the rows
|
|
2914
|
+
// have been published.
|
|
2915
|
+
captureTurnUsage(turn.primaryEntry.message, result);
|
|
2916
|
+
|
|
2917
|
+
const pending = turn.entries.filter((entry) => !entry.settled && entry.turn === turn);
|
|
2918
|
+
if (!pending.length) return;
|
|
2919
|
+
|
|
2920
|
+
if (failure) {
|
|
2921
|
+
const classified = classifyCopilotTurnException(failure);
|
|
2922
|
+
dbg('copilot turn failed', turn.message?.id || '(continuation)', classified.detail);
|
|
2923
|
+
for (const entry of pending) {
|
|
2924
|
+
if (entry.settled) continue;
|
|
2925
|
+
await settleEntry(turn, entry, { type: 'error', classified });
|
|
2926
|
+
}
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
if (result?.isError) {
|
|
2930
|
+
const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
|
|
2931
|
+
dbg('turn failed', turn.message?.id || '(continuation)', classified.stableCode);
|
|
2932
|
+
for (const entry of pending) {
|
|
2933
|
+
if (entry.settled) continue;
|
|
2934
|
+
await settleEntry(turn, entry, { type: 'error', classified });
|
|
2935
|
+
}
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
const live = turn.currentOwner || turn.primaryEntry;
|
|
2940
|
+
// The row the user pressed Stop on. Normally the live one; the relay's
|
|
2941
|
+
// live-turn picker can also land on another row this turn owns, in which
|
|
2942
|
+
// case THAT row is left to the relay's abort control and the live row
|
|
2943
|
+
// keeps its partial reply. A Stop naming a row that is no longer pending
|
|
2944
|
+
// (its early settle already published, or it is no row of this turn's)
|
|
2945
|
+
// still cut the live run short — and nobody else will settle that row.
|
|
2946
|
+
const namedPending = turn.aborted && turn.abortedRowId
|
|
2947
|
+
? pending.find((entry) => String(entry.message?.id || '') === turn.abortedRowId) || null
|
|
2948
|
+
: null;
|
|
2949
|
+
const stopped = turn.aborted ? namedPending || (turn.abortedRowId ? null : live) : null;
|
|
2950
|
+
const cutRun = turn.aborted && !stopped && !live.settled ? live : null;
|
|
2951
|
+
const settleOne = async (entry) => {
|
|
2952
|
+
// A row that moved to a turn of its own (adoptOrphanedRun) is not ours.
|
|
2953
|
+
if (entry.turn !== turn || entry.settled) return;
|
|
2954
|
+
const ownsRun = entry === live || (!entry.foldedInto && entry.consumed);
|
|
2955
|
+
if (turn.aborted) {
|
|
2956
|
+
// A user Stop. The stopped row is settled server-side; every other
|
|
2957
|
+
// row pushed into the interaction went unanswered — the runtime
|
|
2958
|
+
// clears both its lanes on an interrupt — and says so, with Resend.
|
|
2959
|
+
if (entry === stopped) {
|
|
2960
|
+
dbg('turn aborted', entry.message.id);
|
|
2961
|
+
await settleEntry(turn, entry, { type: 'aborted-by-user' });
|
|
2962
|
+
} else if (entry === cutRun) {
|
|
2963
|
+
dbg('turn aborted by a Stop that named another row', entry.message.id, `named ${turn.abortedRowId}`);
|
|
2964
|
+
await settleEntry(turn, entry, { type: 'user-stopped' });
|
|
2965
|
+
} else if (ownsRun) {
|
|
2966
|
+
await settleEntry(turn, entry, { type: 'completed' });
|
|
2967
|
+
} else {
|
|
2968
|
+
await settleEntry(turn, entry, { type: 'stopped' });
|
|
2969
|
+
}
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
if (result?.aborted) {
|
|
2973
|
+
// The runtime interrupted the turn on its own. Nothing server-side is
|
|
2974
|
+
// waiting to settle these rows.
|
|
2975
|
+
if (entry === live) {
|
|
2976
|
+
dbg('turn interrupted by the runtime', entry.message.id);
|
|
2977
|
+
await settleEntry(turn, entry, { type: 'runtime-interrupted' });
|
|
2978
|
+
} else if (ownsRun) {
|
|
2979
|
+
await settleEntry(turn, entry, { type: 'completed' });
|
|
2980
|
+
} else {
|
|
2981
|
+
await settleEntry(turn, entry, { type: 'stopped' });
|
|
2982
|
+
}
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
if (ownsRun) {
|
|
2986
|
+
await settleEntry(turn, entry, { type: 'completed' });
|
|
2987
|
+
} else if (entry.foldedInto) {
|
|
2988
|
+
await settleEntry(turn, entry, { type: 'folded' });
|
|
2989
|
+
} else {
|
|
2990
|
+
await settleEntry(turn, entry, { type: 'merged-note' });
|
|
2991
|
+
}
|
|
2992
|
+
};
|
|
2993
|
+
// A normal end with pushed prompts the runtime never started: the runtime
|
|
2994
|
+
// gets a moment to open their runs (adoptOrphanedRun takes them) before
|
|
2995
|
+
// they are noted as carried over — AFTER the rows whose replies are
|
|
2996
|
+
// complete have been published, so a finished answer never waits on the
|
|
2997
|
+
// grace. Not on a Stop (the runtime cleared them) and not on an error.
|
|
2998
|
+
const orphans = (!turn.aborted && !result?.aborted)
|
|
2999
|
+
? pending.filter((entry) => entry.role === 'steer' && !entry.consumed && !entry.foldedInto)
|
|
3000
|
+
: [];
|
|
3001
|
+
if (turn.aborted || result?.aborted) {
|
|
3002
|
+
// Belt and braces: `interruptMainTurn({flushQueued:false})` and
|
|
3003
|
+
// `abort()` both empty the runtime's steering AND queued lanes
|
|
3004
|
+
// (probe-verified 2026-09-25), so nothing pushed runs after a Stop. Any
|
|
3005
|
+
// prompt of ours still listed in the queued lane is removed by id anyway
|
|
3006
|
+
// before its row is marked stopped. (The id-less steering lane cannot be
|
|
3007
|
+
// targeted, and needs no help.)
|
|
3008
|
+
await clearOwnedQueuedItems(pending.filter((entry) => !entry.consumed && entry.runtimeId));
|
|
3009
|
+
}
|
|
3010
|
+
// The stopped row first: settling it before the live row's cascade means
|
|
3011
|
+
// the cascade (which settles the steers folded into a run) cannot touch
|
|
3012
|
+
// the row the relay's abort control owns. A cut run goes first too, so
|
|
3013
|
+
// the steers folded into it settle through its cascade, after its stop.
|
|
3014
|
+
const first = stopped || cutRun;
|
|
3015
|
+
const ordered = first ? [first, ...pending.filter((entry) => entry !== first)] : pending;
|
|
3016
|
+
for (const entry of ordered) {
|
|
3017
|
+
if (!orphans.includes(entry)) await settleOne(entry);
|
|
3018
|
+
}
|
|
3019
|
+
for (const entry of await settleAfterOrphanGrace(turn, orphans)) {
|
|
3020
|
+
await settleOne(entry);
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// ------------------------------------------------------------ lifecycle --
|
|
3025
|
+
|
|
3026
|
+
/**
|
|
3027
|
+
* The turn a Stop is for. Every turn's control poller runs until its
|
|
3028
|
+
* `driveTurn` finishes, so a turn that is still publishing can claim a Stop
|
|
3029
|
+
* meant for another: the Stop goes to the turn owning the row it names, and
|
|
3030
|
+
* one naming no owned row goes to the turn that is running.
|
|
3031
|
+
*/
|
|
3032
|
+
function stopTarget(turn, control) {
|
|
3033
|
+
const rowId = String(control?.queueMessageId || '').trim();
|
|
3034
|
+
if (rowId) {
|
|
3035
|
+
for (const candidate of [activeTurn, ...publishingTurns]) {
|
|
3036
|
+
if (candidate?.entries.some((entry) => !entry.released && String(entry.message?.id || '') === rowId)) {
|
|
3037
|
+
return candidate;
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
return turn.settled && activeTurn ? activeTurn : turn;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
/**
|
|
3045
|
+
* Handle a Stop for `turn`. When the row it names settled early and its
|
|
3046
|
+
* reply is still publishing, the Stop resolves only once that reply has
|
|
3047
|
+
* landed: the relay's ack then fails the named row, which is a no-op on a
|
|
3048
|
+
* completed one but would overwrite a reply still in flight.
|
|
3049
|
+
*/
|
|
3050
|
+
async function interruptTurn(turn, control) {
|
|
3051
|
+
const rowId = String(control?.queueMessageId || '').trim() || null;
|
|
3052
|
+
const publishing = rowId
|
|
3053
|
+
? turn.entries.find((entry) => String(entry.message?.id || '') === rowId && entry.settled && !entry.released)
|
|
3054
|
+
: null;
|
|
3055
|
+
await interruptRun(turn, rowId);
|
|
3056
|
+
if (publishing) await withTimeout(publishing.done, STOPPED_ROW_PUBLISH_WAIT_MS, false);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
/**
|
|
3060
|
+
* Interrupt the turn the user stopped. `interruptMainTurn` (when the
|
|
3061
|
+
* runtime has it) stops the main agent loop and nothing else: background
|
|
3062
|
+
* agents and promoted shells keep running under their own Stop, exactly
|
|
3063
|
+
* like the Claude worker's targeted interrupt. `session.abort()` — the
|
|
3064
|
+
* fallback — also cancels every background agent and clears both message
|
|
3065
|
+
* lanes. Either way the runtime answers with an aborted idle event, which
|
|
3066
|
+
* settles the turn through the normal terminator.
|
|
3067
|
+
*/
|
|
3068
|
+
async function interruptRun(turn, rowId) {
|
|
3069
|
+
if (turn.settled) {
|
|
3070
|
+
// Its run is over and the main loop may already belong to the next
|
|
3071
|
+
// turn, so nothing is interrupted. While it waits on a card (driveTurn)
|
|
3072
|
+
// the Stop ends the cards, and the row settles as stopped.
|
|
3073
|
+
if (turn.humanRequests > 0) {
|
|
3074
|
+
turn.aborted = true;
|
|
3075
|
+
turn.abortedRowId = rowId;
|
|
3076
|
+
turn.abortController.abort();
|
|
3077
|
+
}
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
3080
|
+
turn.aborted = true;
|
|
3081
|
+
turn.abortedRowId = rowId;
|
|
3082
|
+
// A pending `remote_relay` call ends now rather than when the runtime gets
|
|
3083
|
+
// round to it: the relay drops the request, withdraws its approval card
|
|
3084
|
+
// and stops waiting on the remote, and the watchdog hold is released.
|
|
3085
|
+
turn.remoteRelayStop?.abort();
|
|
3086
|
+
// While `ensureSession` is still connecting there is no session to abort
|
|
3087
|
+
// and this would be a silent no-op; `runTurn` re-checks `turn.aborted`
|
|
3088
|
+
// once the session exists and settles there instead.
|
|
3089
|
+
if (!session) return;
|
|
3090
|
+
if (sessionRpc.interruptMainTurn) {
|
|
3091
|
+
try {
|
|
3092
|
+
const outcome = await session.rpc.interruptMainTurn({ flushQueued: false });
|
|
3093
|
+
if (outcome?.interrupted === false) {
|
|
3094
|
+
// Nothing was processing: no aborted idle will come. The turn is
|
|
3095
|
+
// over as far as the runtime is concerned.
|
|
3096
|
+
dbg('interruptMainTurn found no main turn in flight; settling locally', turn.message?.id || '');
|
|
3097
|
+
turn.settle();
|
|
3098
|
+
}
|
|
3099
|
+
return;
|
|
3100
|
+
} catch (error) {
|
|
3101
|
+
dbg('interruptMainTurn failed; falling back to abort()', error?.message || String(error));
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
await session.abort?.();
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
/**
|
|
3108
|
+
* Start a delivered turn: bring the session up, send the prompt, and hand
|
|
3109
|
+
* the turn's lifecycle to `driveTurn`. Resolves when THIS row has settled —
|
|
3110
|
+
* which can be before the turn ends, when a message pushed later ran as its
|
|
3111
|
+
* own run and this one's reply was already complete.
|
|
3112
|
+
*/
|
|
1475
3113
|
async function runTurn(turn) {
|
|
1476
3114
|
const { message } = turn;
|
|
3115
|
+
const entry = turn.primaryEntry;
|
|
1477
3116
|
const model = resolvePerTurnModel(message);
|
|
1478
3117
|
// The dequeued row's reasoning effort (audit #9). Normalisation happens in
|
|
1479
3118
|
// the switcher: `none`/absent mean "the model's default".
|
|
1480
3119
|
const effort = message?.reasoningEffort ?? null;
|
|
1481
3120
|
const relayMode = message?.relayMode || 'agent';
|
|
1482
3121
|
lastRelayMode = relayMode;
|
|
3122
|
+
turn.model = model;
|
|
1483
3123
|
// Set before the session is touched: the heartbeat's owner-recovery guard
|
|
1484
3124
|
// reads the active ids, so a cold-start delivery must already own its row.
|
|
1485
3125
|
activeTurn = turn;
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
// `assistant.turn_end` → `session.idle{aborted:true}`, which settles
|
|
1497
|
-
// the turn through the normal terminator; this only asks for it.
|
|
1498
|
-
await session.abort?.();
|
|
1499
|
-
},
|
|
1500
|
-
});
|
|
1501
|
-
|
|
3126
|
+
// The delivery hold begins: nothing steers in until the prompt is sent.
|
|
3127
|
+
syncDeliveryReadiness();
|
|
3128
|
+
// Session-wide, not per row: the client's Stop targets whichever of this
|
|
3129
|
+
// turn's rows is live, and the relay only hands out controls for rows
|
|
3130
|
+
// this worker owns.
|
|
3131
|
+
turn.controlState = controlPoller?.start?.({
|
|
3132
|
+
queueMessageId: '',
|
|
3133
|
+
onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
|
|
3134
|
+
}) || null;
|
|
3135
|
+
turn.drive = driveTurn(turn);
|
|
1502
3136
|
try {
|
|
1503
|
-
await ensureSession(model, effort
|
|
3137
|
+
await ensureSession(model, effort);
|
|
1504
3138
|
if (turn.aborted) {
|
|
1505
3139
|
// The abort landed while the session was still being built. Nothing
|
|
1506
|
-
// was sent, so there is no runtime turn to interrupt —
|
|
1507
|
-
//
|
|
1508
|
-
// settle locally rather than sending a prompt the user just cancelled.
|
|
3140
|
+
// was sent, so there is no runtime turn to interrupt — settle locally
|
|
3141
|
+
// rather than sending a prompt the user just cancelled.
|
|
1509
3142
|
dbg('turn aborted before send', message.id);
|
|
1510
|
-
try { await session?.abort?.(); } catch (error) {
|
|
1511
|
-
dbg('abort during session setup failed', error?.message || String(error));
|
|
1512
|
-
}
|
|
1513
3143
|
turn.settle();
|
|
1514
3144
|
} else {
|
|
1515
3145
|
turn.armStall();
|
|
@@ -1522,26 +3152,147 @@ export function createCopilotSdkSessionRunner({
|
|
|
1522
3152
|
// `mode` and `attachments` are FIELDS of the single MessageOptions
|
|
1523
3153
|
// argument — `send()` takes no second parameter, so passing options
|
|
1524
3154
|
// positionally drops them silently.
|
|
1525
|
-
|
|
3155
|
+
const sending = session.send({
|
|
1526
3156
|
prompt,
|
|
1527
3157
|
...(attachments?.length ? { attachments } : {}),
|
|
1528
3158
|
...(sendMode ? { mode: sendMode } : {}),
|
|
1529
3159
|
agentMode: copilotAgentModeForRelayMode(relayMode),
|
|
1530
3160
|
});
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
3161
|
+
// A send that never resolves (a wedged runtime) must not hold this
|
|
3162
|
+
// delivery hostage once the watchdog has failed the turn: the id is
|
|
3163
|
+
// still recorded if it ever arrives, but the wait ends with the turn.
|
|
3164
|
+
const settledFirst = Symbol('turn-settled');
|
|
3165
|
+
const runtimeId = await Promise.race([
|
|
3166
|
+
sending,
|
|
3167
|
+
turn.done.then(() => settledFirst, () => settledFirst),
|
|
3168
|
+
]);
|
|
3169
|
+
if (runtimeId === settledFirst) {
|
|
3170
|
+
sending.then((id) => assignRuntimeId(turn, entry, id)).catch(() => {});
|
|
3171
|
+
} else {
|
|
3172
|
+
assignRuntimeId(turn, entry, runtimeId);
|
|
3173
|
+
// Committed only now that `send()` accepted the prompt (audit #32): a
|
|
3174
|
+
// failed send means the runtime never READ the mode guidance, and
|
|
3175
|
+
// committing before it would make the same-mode retry omit it.
|
|
3176
|
+
context?.commit();
|
|
3177
|
+
// The runtime has the prompt: the delivery hold ends and later
|
|
3178
|
+
// deliveries steer into this turn.
|
|
3179
|
+
turn.primarySent = true;
|
|
3180
|
+
turn.resolvePrimaryReady();
|
|
3181
|
+
syncDeliveryReadiness();
|
|
3182
|
+
}
|
|
1538
3183
|
}
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
|
|
3184
|
+
} catch (error) {
|
|
3185
|
+
// A failure that killed the session (or came from starting it) leaves a
|
|
3186
|
+
// handle nothing else can use; drop it so the next delivery rebuilds and
|
|
3187
|
+
// resumes rather than sending into a dead runtime. An UNCONFIRMED MODEL
|
|
3188
|
+
// SWITCH is the exception: the session is healthy and merely still on
|
|
3189
|
+
// its previous model, and tearing the runtime down over it would kill
|
|
3190
|
+
// any live background work for a selection problem the user fixes by
|
|
3191
|
+
// picking another model.
|
|
3192
|
+
if (!isModelSwitchUnconfirmedError(error)) {
|
|
3193
|
+
await stopRuntime('turn-failure').catch(() => {});
|
|
3194
|
+
}
|
|
3195
|
+
turn.fail(error);
|
|
1542
3196
|
}
|
|
3197
|
+
// `send()` resolving is NOT the turn's completion; the event stream is.
|
|
3198
|
+
await awaitEntry(turn, entry);
|
|
3199
|
+
return true;
|
|
3200
|
+
}
|
|
1543
3201
|
|
|
1544
|
-
|
|
3202
|
+
/**
|
|
3203
|
+
* Wait for a row's settle. A row's settle is not necessarily the turn's
|
|
3204
|
+
* end: a prompt pushed later may still be running on the same interaction,
|
|
3205
|
+
* and this delivery must not wait for it. But when the turn IS over, wait
|
|
3206
|
+
* for its wrap-up too (ownership release, usage ingest), so a caller that
|
|
3207
|
+
* awaited the delivery sees a quiescent runner — the contract every test
|
|
3208
|
+
* and the crash guard rely on. `driveTurn` never rejects.
|
|
3209
|
+
*/
|
|
3210
|
+
async function awaitEntry(turn, entry) {
|
|
3211
|
+
await entry.done;
|
|
3212
|
+
if (turn.settled && turn.drive) await turn.drive.catch(() => {});
|
|
3213
|
+
}
|
|
3214
|
+
|
|
3215
|
+
/**
|
|
3216
|
+
* Run a turn from its first event to its last publish.
|
|
3217
|
+
*
|
|
3218
|
+
* Row ownership is released only after every publish has landed: a
|
|
3219
|
+
* heartbeat firing inside the publish window with no active ids would tell
|
|
3220
|
+
* the relay this worker owns nothing, and the still-`processing` rows would
|
|
3221
|
+
* be recovered underneath it. (The EVENT stream was already released at the
|
|
3222
|
+
* terminator — see `beginPublishing` — so the runtime's next self-initiated
|
|
3223
|
+
* turn can open while this one is still writing.)
|
|
3224
|
+
*/
|
|
3225
|
+
async function driveTurn(turn) {
|
|
3226
|
+
try {
|
|
3227
|
+
await turn.done;
|
|
3228
|
+
} catch (error) {
|
|
3229
|
+
turn.failure = turn.failure || error;
|
|
3230
|
+
}
|
|
3231
|
+
// A card still open on this row — a background agent asking while the
|
|
3232
|
+
// main loop went idle (a root question cannot get here: the loop is
|
|
3233
|
+
// blocked on its handler) — keeps the row processing until it is done:
|
|
3234
|
+
// the relay opens cards only on a processing row, and `/api/response`
|
|
3235
|
+
// cancels the ones still open. The event stream is already released
|
|
3236
|
+
// (`beginPublishing`), so new root work opens a turn of its own meanwhile.
|
|
3237
|
+
if (!turn.aborted && !turn.failure && turn.humanRequests > 0) await turn.humansIdle;
|
|
3238
|
+
try {
|
|
3239
|
+
await quiesceTurn(turn);
|
|
3240
|
+
if (turn.failure && turn.kind === 'delivered' && !isModelSwitchUnconfirmedError(turn.failure) && session) {
|
|
3241
|
+
// A stall or a runtime death mid-turn (a setup failure already tore
|
|
3242
|
+
// the runtime down in runTurn).
|
|
3243
|
+
await stopRuntime('turn-failure').catch(() => {});
|
|
3244
|
+
}
|
|
3245
|
+
if (turn.kind === 'continuation') {
|
|
3246
|
+
// Nothing can be published before the row exists. The buffer is
|
|
3247
|
+
// drained by the registration itself; this only covers actions that
|
|
3248
|
+
// arrived during the drain.
|
|
3249
|
+
await awaitContinuationRow(turn);
|
|
3250
|
+
// The invariant is the id, not the flag: registration can also give up
|
|
3251
|
+
// by throwing, or by taking longer than `awaitContinuationRow` waits,
|
|
3252
|
+
// and publishing against `messageId: null` would attribute the whole
|
|
3253
|
+
// turn to nothing at all.
|
|
3254
|
+
if (!turn.message.id) {
|
|
3255
|
+
if (!turn.discarded) abandonContinuationRegistration(turn, 'no relay row at publish time');
|
|
3256
|
+
dbg('continuation output dropped (no relay row)');
|
|
3257
|
+
const classified = classifyCopilotTurnException(
|
|
3258
|
+
turn.failure || new Error('the relay refused a continuation row for this turn'),
|
|
3259
|
+
);
|
|
3260
|
+
for (const entry of turn.entries) {
|
|
3261
|
+
if (entry.settled || entry.turn !== turn) continue;
|
|
3262
|
+
// The steering path is still waiting on any row it handed us, and
|
|
3263
|
+
// it has no row of its own to fall back to.
|
|
3264
|
+
await settleEntry(turn, entry, entry.role === 'self' ? { type: 'discarded' } : { type: 'error', classified });
|
|
3265
|
+
}
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
await flushBufferedActions(turn);
|
|
3269
|
+
}
|
|
3270
|
+
await finishTurn(turn);
|
|
3271
|
+
} catch (error) {
|
|
3272
|
+
dbg('turn publish failed', error?.message || String(error));
|
|
3273
|
+
} finally {
|
|
3274
|
+
// Early settles still in flight must land before the rows stop being
|
|
3275
|
+
// reported (settleEntry never rejects, but this is the ordering guard).
|
|
3276
|
+
await Promise.allSettled(turn.settlePromises);
|
|
3277
|
+
controlPoller?.stop?.(turn.controlState);
|
|
3278
|
+
turn.controlState = null;
|
|
3279
|
+
// Every path through the turn — published, failed, aborted, threw — has
|
|
3280
|
+
// finished by here. Anything still unresolved is released so no caller
|
|
3281
|
+
// waits forever; its row is left to the relay's recovery.
|
|
3282
|
+
for (const entry of turn.entries) {
|
|
3283
|
+
if (entry.turn !== turn || entry.released) continue;
|
|
3284
|
+
entry.settled = true;
|
|
3285
|
+
entry.released = true;
|
|
3286
|
+
entry.resolveDone(true);
|
|
3287
|
+
}
|
|
3288
|
+
// Released only once every publish for this turn's rows has landed.
|
|
3289
|
+
releaseTurnOwnership(turn);
|
|
3290
|
+
touch();
|
|
3291
|
+
syncDeliveryReadiness();
|
|
3292
|
+
// The ingest is advisory, never awaited, and must never be able to
|
|
3293
|
+
// delay a reply that is already written.
|
|
3294
|
+
postTurnUsage();
|
|
3295
|
+
}
|
|
1545
3296
|
}
|
|
1546
3297
|
|
|
1547
3298
|
/**
|
|
@@ -1563,111 +3314,6 @@ export function createCopilotSdkSessionRunner({
|
|
|
1563
3314
|
await closeStraySubagentRuns(turn).catch(() => {});
|
|
1564
3315
|
}
|
|
1565
3316
|
|
|
1566
|
-
/**
|
|
1567
|
-
* Publish a finished turn's outcome onto its queue row (and every row steered
|
|
1568
|
-
* into it).
|
|
1569
|
-
*
|
|
1570
|
-
* Shared by both turn kinds. A continuation reaches it through
|
|
1571
|
-
* `driveContinuation` instead of `runTurn`, but the outcomes are identical:
|
|
1572
|
-
* the runtime does not distinguish a turn it started from one it was asked
|
|
1573
|
-
* for, so neither does the reporting.
|
|
1574
|
-
*/
|
|
1575
|
-
async function finishTurn(turn, model) {
|
|
1576
|
-
const { message } = turn;
|
|
1577
|
-
const result = turn.result;
|
|
1578
|
-
const responseModel = result?.model || turn.normalizer.model || model || null;
|
|
1579
|
-
// Capture only. The POST fires from `handlePendingPayload`'s `finally`,
|
|
1580
|
-
// after this row has been published.
|
|
1581
|
-
captureTurnUsage(message, result);
|
|
1582
|
-
|
|
1583
|
-
// Whatever this interaction did not get to stays queued in the runtime and
|
|
1584
|
-
// shifts the NEXT interaction's segments: the prompts this turn sent occupy
|
|
1585
|
-
// `[firstSentSegment, nextSegmentIndex)`, and the ones the runtime never
|
|
1586
|
-
// opened a segment for are still in its pending queue.
|
|
1587
|
-
//
|
|
1588
|
-
// The `max` against `firstSentSegment` is what makes this correct for a
|
|
1589
|
-
// continuation, which occupies segment 0 without having sent anything: a
|
|
1590
|
-
// continuation that opened no segment at all (an empty self-initiated turn)
|
|
1591
|
-
// would otherwise report one carried prompt and shift the next real turn's
|
|
1592
|
-
// answer by one.
|
|
1593
|
-
const segmentsOpened = turn.normalizer.promptCount();
|
|
1594
|
-
carriedPrompts = Math.max(0, turn.nextSegmentIndex - Math.max(turn.firstSentSegment, segmentsOpened));
|
|
1595
|
-
|
|
1596
|
-
// This row's own reply. Falls back to the whole composed text when the
|
|
1597
|
-
// runtime opened no segment at all (an empty or immediately-failed turn),
|
|
1598
|
-
// which is strictly better than publishing nothing.
|
|
1599
|
-
const ownText = String(
|
|
1600
|
-
turn.normalizer.segmentText(turn.baseSegment) || result?.text || turn.state.lastStreamedText || '',
|
|
1601
|
-
).trim();
|
|
1602
|
-
|
|
1603
|
-
// A user-initiated abort publishes the partial text and nothing else: the
|
|
1604
|
-
// queue row's fate belongs to the server-side abort control, exactly as in
|
|
1605
|
-
// the Claude and Cursor workers. Publishing a response here would
|
|
1606
|
-
// double-settle the row.
|
|
1607
|
-
if (turn.aborted) {
|
|
1608
|
-
dbg('turn aborted', message.id);
|
|
1609
|
-
// This row's segment only: the steered rows publish their own, and
|
|
1610
|
-
// publishing the composed text here would show their replies twice.
|
|
1611
|
-
await publishFinalStream(message, ownText);
|
|
1612
|
-
// A steered row is NOT covered by the abort control (which knows only
|
|
1613
|
-
// about the row the user aborted), so it still has to be settled here or
|
|
1614
|
-
// it holds `processing` forever behind a renewing lease.
|
|
1615
|
-
await settleSteeredRows(turn, result, responseModel);
|
|
1616
|
-
return true;
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
// The runtime aborted on its own (`result.aborted` with no relay-side
|
|
1620
|
-
// abort control in flight). Nothing server-side is waiting to settle this
|
|
1621
|
-
// row, so returning here would leave it pending until the delivery
|
|
1622
|
-
// watchdog fails it with a misleading "Relay timeout" — the row has to be
|
|
1623
|
-
// settled with a record that says what actually happened.
|
|
1624
|
-
if (result?.aborted) {
|
|
1625
|
-
dbg('turn interrupted by the runtime', message.id);
|
|
1626
|
-
const text = ownText ? `${ownText}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
|
|
1627
|
-
await publishFinalStream(message, text);
|
|
1628
|
-
await publishResponse(message, { text, model: responseModel });
|
|
1629
|
-
await settleSteeredRows(turn, result, responseModel);
|
|
1630
|
-
return true;
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
if (result?.isError) {
|
|
1634
|
-
const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
|
|
1635
|
-
dbg('turn failed', message.id, classified.stableCode);
|
|
1636
|
-
await publishFinalStream(message, turn.state.lastStreamedText);
|
|
1637
|
-
await publishResponse(message, {
|
|
1638
|
-
text: classified.text,
|
|
1639
|
-
model: responseModel,
|
|
1640
|
-
terminalError: terminalErrorRecord(message, classified),
|
|
1641
|
-
});
|
|
1642
|
-
// The steered rows failed with it — they were being answered by the same
|
|
1643
|
-
// interaction. They get the same terminal record so each row says why.
|
|
1644
|
-
await settleSteeredRows(turn, result, responseModel, classified);
|
|
1645
|
-
return true;
|
|
1646
|
-
}
|
|
1647
|
-
|
|
1648
|
-
// The turn produced a plan but never called exit-plan-mode (or the runtime
|
|
1649
|
-
// build has no such hook). Same text-shape fallback the siblings use, and
|
|
1650
|
-
// it must go out BEFORE the response: `/api/relay-board` 409s once the
|
|
1651
|
-
// queue row leaves `processing`.
|
|
1652
|
-
if (shouldPostPlanBoard({
|
|
1653
|
-
relayMode: message.relayMode,
|
|
1654
|
-
finalText: ownText,
|
|
1655
|
-
alreadyPosted: turn.planBoardPosted,
|
|
1656
|
-
acted: turn.acted === true,
|
|
1657
|
-
})) {
|
|
1658
|
-
await publishPlanBoard(turn, ownText, 'plan-mode-fallback');
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
// A terminal, non-error turn with no prose is COMPLETE, not a failed
|
|
1662
|
-
// delivery — requeuing re-runs deterministically empty work until the
|
|
1663
|
-
// retry cap fails the row with a misleading "Relay timeout".
|
|
1664
|
-
const publishedText = ownText || EMPTY_TURN_COMPLETION_NOTE;
|
|
1665
|
-
await publishFinalStream(message, publishedText);
|
|
1666
|
-
await publishResponse(message, { text: publishedText, model: responseModel });
|
|
1667
|
-
await settleSteeredRows(turn, result, responseModel);
|
|
1668
|
-
return true;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
3317
|
// ----------------------------------------------------------- continuation --
|
|
1672
3318
|
|
|
1673
3319
|
/**
|
|
@@ -1696,14 +3342,15 @@ export function createCopilotSdkSessionRunner({
|
|
|
1696
3342
|
// The gap this pin covers has closed.
|
|
1697
3343
|
continuationDueSince = 0;
|
|
1698
3344
|
turn.armStall();
|
|
1699
|
-
// Lines produced between turns (a settled shell's
|
|
1700
|
-
// the turn they triggered.
|
|
3345
|
+
// Lines produced between turns (a settled shell's or background agent's
|
|
3346
|
+
// notification) belong to the turn they triggered.
|
|
1701
3347
|
if (pendingActivities.length) {
|
|
1702
|
-
for (const text of pendingActivities.splice(0)) {
|
|
1703
|
-
turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null } });
|
|
3348
|
+
for (const { text, reportsFailure } of pendingActivities.splice(0)) {
|
|
3349
|
+
turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null }, carried: true, reportsFailure });
|
|
1704
3350
|
}
|
|
1705
3351
|
}
|
|
1706
3352
|
dbg('opening a continuation turn for runtime-initiated work');
|
|
3353
|
+
syncDeliveryReadiness();
|
|
1707
3354
|
// Both are fire-and-forget by design (the SDK's event callback is
|
|
1708
3355
|
// synchronous and cannot await a turn), so both must swallow: an unhandled
|
|
1709
3356
|
// rejection here would reach the worker crash guard and take the whole
|
|
@@ -1717,16 +3364,54 @@ export function createCopilotSdkSessionRunner({
|
|
|
1717
3364
|
// has somewhere to go.
|
|
1718
3365
|
turn.registered = true;
|
|
1719
3366
|
turn.resolveRowReady?.(true);
|
|
3367
|
+
syncDeliveryReadiness();
|
|
1720
3368
|
return;
|
|
1721
3369
|
}
|
|
1722
3370
|
abandonContinuationRegistration(turn, 'registration threw');
|
|
1723
3371
|
});
|
|
1724
|
-
|
|
3372
|
+
turn.drive = driveTurn(turn);
|
|
3373
|
+
turn.drive.catch((error) => {
|
|
1725
3374
|
dbg('continuation driver threw', error?.message || String(error));
|
|
1726
3375
|
});
|
|
1727
3376
|
return turn;
|
|
1728
3377
|
}
|
|
1729
3378
|
|
|
3379
|
+
/**
|
|
3380
|
+
* A pushed row whose run the runtime opened AFTER the turn it was pushed
|
|
3381
|
+
* into had settled (the drain's idle raced the send). It is not a
|
|
3382
|
+
* self-initiated turn — it is that row's own turn, and its reply belongs on
|
|
3383
|
+
* that row. Returns the new turn, or null when the id is not one of ours.
|
|
3384
|
+
*/
|
|
3385
|
+
function adoptOrphanedRun(event) {
|
|
3386
|
+
if (String(event?.type || '') !== 'user.message' || event?.agentId) return null;
|
|
3387
|
+
const runtimeId = String(event?.data?.messageId || '').trim();
|
|
3388
|
+
if (!runtimeId) return null;
|
|
3389
|
+
for (const previous of publishingTurns) {
|
|
3390
|
+
const entry = previous.pushed.get(runtimeId);
|
|
3391
|
+
if (!entry || entry.consumed || entry.settled) continue;
|
|
3392
|
+
previous.entries = previous.entries.filter((other) => other !== entry);
|
|
3393
|
+
previous.pushed.delete(runtimeId);
|
|
3394
|
+
// From here the entry belongs to the new turn (`entry.turn`); the old
|
|
3395
|
+
// turn's settle passes skip rows that moved away.
|
|
3396
|
+
const turn = createTurn(entry.message, { kind: 'delivered', adoptEntry: entry });
|
|
3397
|
+
turn.model = resolvePerTurnModel(entry.message);
|
|
3398
|
+
activeTurn = turn;
|
|
3399
|
+
turn.armStall();
|
|
3400
|
+
turn.controlState = controlPoller?.start?.({
|
|
3401
|
+
queueMessageId: '',
|
|
3402
|
+
onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
|
|
3403
|
+
}) || null;
|
|
3404
|
+
dbg('a pushed prompt opened its own run after its turn settled; adopting it', entry.message.id);
|
|
3405
|
+
syncDeliveryReadiness();
|
|
3406
|
+
turn.drive = driveTurn(turn);
|
|
3407
|
+
turn.drive.catch((error) => {
|
|
3408
|
+
dbg('adopted turn driver threw', error?.message || String(error));
|
|
3409
|
+
});
|
|
3410
|
+
return turn;
|
|
3411
|
+
}
|
|
3412
|
+
return null;
|
|
3413
|
+
}
|
|
3414
|
+
|
|
1730
3415
|
/**
|
|
1731
3416
|
* Give up on a continuation's synthetic row: nothing buffered will ever
|
|
1732
3417
|
* publish, and a registration still in flight must not adopt a row into this
|
|
@@ -1738,8 +3423,10 @@ export function createCopilotSdkSessionRunner({
|
|
|
1738
3423
|
turn.discarded = true;
|
|
1739
3424
|
turn.bufferedActions = [];
|
|
1740
3425
|
turn.registrationAbort?.abort?.();
|
|
1741
|
-
// Interactive handlers stop waiting and take their degraded path
|
|
3426
|
+
// Interactive handlers stop waiting and take their degraded path, and the
|
|
3427
|
+
// delivery hold that waited for the row ends.
|
|
1742
3428
|
turn.resolveRowReady?.(false);
|
|
3429
|
+
syncDeliveryReadiness();
|
|
1743
3430
|
dbg('continuation registration abandoned:', reason);
|
|
1744
3431
|
}
|
|
1745
3432
|
|
|
@@ -1813,16 +3500,14 @@ export function createCopilotSdkSessionRunner({
|
|
|
1813
3500
|
// Only now is there a row to abort, so this is where the control poller can
|
|
1814
3501
|
// start.
|
|
1815
3502
|
turn.controlState = controlPoller?.start?.({
|
|
1816
|
-
queueMessageId:
|
|
1817
|
-
onAbortTurn:
|
|
1818
|
-
turn.aborted = true;
|
|
1819
|
-
if (!session) return;
|
|
1820
|
-
await session.abort?.();
|
|
1821
|
-
},
|
|
3503
|
+
queueMessageId: '',
|
|
3504
|
+
onAbortTurn: (control) => interruptTurn(stopTarget(turn, control), control),
|
|
1822
3505
|
}) || null;
|
|
1823
3506
|
await flushBufferedActions(turn);
|
|
1824
3507
|
turn.registered = true;
|
|
1825
3508
|
turn.resolveRowReady?.(true);
|
|
3509
|
+
// The row exists: the delivery hold it imposed ends (canAcceptSteering).
|
|
3510
|
+
syncDeliveryReadiness();
|
|
1826
3511
|
}
|
|
1827
3512
|
|
|
1828
3513
|
/**
|
|
@@ -1846,184 +3531,347 @@ export function createCopilotSdkSessionRunner({
|
|
|
1846
3531
|
}
|
|
1847
3532
|
}
|
|
1848
3533
|
|
|
1849
|
-
|
|
1850
|
-
* Run a continuation to its terminator and publish it, mirroring
|
|
1851
|
-
* `handlePendingPayload`'s outer shape.
|
|
1852
|
-
*
|
|
1853
|
-
* Row ownership is released only after every publish has landed, exactly as
|
|
1854
|
-
* for a delivered turn: a heartbeat firing inside the publish window with no
|
|
1855
|
-
* active ids would tell the relay this worker owns nothing, and the
|
|
1856
|
-
* still-`processing` synthetic row would be recovered underneath it. (The
|
|
1857
|
-
* EVENT stream was already released at the terminator — see
|
|
1858
|
-
* `beginPublishing` — so the runtime's next self-initiated turn can open
|
|
1859
|
-
* while this one is still writing.)
|
|
1860
|
-
*/
|
|
1861
|
-
async function driveContinuation(turn) {
|
|
1862
|
-
let failure = null;
|
|
1863
|
-
try {
|
|
1864
|
-
await turn.done;
|
|
1865
|
-
} catch (error) {
|
|
1866
|
-
failure = error;
|
|
1867
|
-
}
|
|
1868
|
-
try {
|
|
1869
|
-
await quiesceTurn(turn);
|
|
1870
|
-
// Nothing can be published before the row exists. The buffer is drained
|
|
1871
|
-
// by the registration itself; this only covers actions that arrived
|
|
1872
|
-
// during the drain.
|
|
1873
|
-
await awaitContinuationRow(turn);
|
|
1874
|
-
// The invariant is the id, not the flag: registration can also give up by
|
|
1875
|
-
// throwing, or by taking longer than `awaitContinuationRow` waits, and
|
|
1876
|
-
// publishing against `messageId: null` would attribute the whole turn to
|
|
1877
|
-
// nothing at all.
|
|
1878
|
-
if (!turn.message.id) {
|
|
1879
|
-
if (!turn.discarded) abandonContinuationRegistration(turn, 'no relay row at publish time');
|
|
1880
|
-
dbg('continuation output dropped (no relay row)');
|
|
1881
|
-
// The steering path is still waiting on any row it handed us, and it
|
|
1882
|
-
// has no row of its own to fall back to.
|
|
1883
|
-
await settleSteeredRows(turn, null, null, classifyCopilotTurnException(
|
|
1884
|
-
failure || new Error('the relay refused a continuation row for this turn'),
|
|
1885
|
-
)).catch(() => {});
|
|
1886
|
-
return;
|
|
1887
|
-
}
|
|
1888
|
-
await flushBufferedActions(turn);
|
|
1889
|
-
if (failure) {
|
|
1890
|
-
const classified = classifyCopilotTurnException(failure);
|
|
1891
|
-
dbg('continuation turn failed', turn.message.id, classified.detail);
|
|
1892
|
-
await publishResponse(turn.message, {
|
|
1893
|
-
text: classified.text,
|
|
1894
|
-
model: null,
|
|
1895
|
-
terminalError: terminalErrorRecord(turn.message, classified),
|
|
1896
|
-
});
|
|
1897
|
-
await settleSteeredRows(turn, null, null, classified).catch(() => {});
|
|
1898
|
-
return;
|
|
1899
|
-
}
|
|
1900
|
-
await finishTurn(turn, '');
|
|
1901
|
-
} catch (error) {
|
|
1902
|
-
dbg('continuation publish failed', error?.message || String(error));
|
|
1903
|
-
} finally {
|
|
1904
|
-
controlPoller?.stop?.(turn.controlState);
|
|
1905
|
-
turn.controlState = null;
|
|
1906
|
-
releaseTurnOwnership(turn);
|
|
1907
|
-
touch();
|
|
1908
|
-
// A continuation spends real quota (the live capture burned a premium
|
|
1909
|
-
// request on `read_bash` + the reply), so its numbers ride the same
|
|
1910
|
-
// fire-and-forget ingest as a delivered turn's.
|
|
1911
|
-
postTurnUsage();
|
|
1912
|
-
}
|
|
1913
|
-
}
|
|
3534
|
+
// --------------------------------------------------------------- steering --
|
|
1914
3535
|
|
|
1915
3536
|
/**
|
|
1916
|
-
*
|
|
3537
|
+
* A delivery arrived while a turn was already running: push it into the
|
|
3538
|
+
* same runtime interaction as a steer.
|
|
1917
3539
|
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
1921
|
-
*
|
|
1922
|
-
*
|
|
3540
|
+
* `mode: "immediate"` makes it a real steer — injected at the next tool
|
|
3541
|
+
* boundary (`delivery:"steering"`, folded into the running reply), or run
|
|
3542
|
+
* as its own turn right after when the model is mid-stream
|
|
3543
|
+
* (`delivery:"queued"`). Either way the row is owned by this turn and
|
|
3544
|
+
* settled by it (see adoptSegment / finishTurn); nothing else will.
|
|
1923
3545
|
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
* pending queue and will be answered at the start of the next turn, so
|
|
1927
|
-
* redelivering it would run the same prompt twice. It gets a note instead —
|
|
1928
|
-
* the same trade the Claude worker makes for a handed-off context.
|
|
1929
|
-
*/
|
|
1930
|
-
async function settleSteeredRows(turn, result, responseModel, classified = null) {
|
|
1931
|
-
if (!turn.steeredRows.length) return;
|
|
1932
|
-
for (const steered of turn.steeredRows) {
|
|
1933
|
-
const { message: steeredMessage, segmentIndex } = steered;
|
|
1934
|
-
let text = String(turn.normalizer.segmentText(segmentIndex) || '').trim();
|
|
1935
|
-
if (classified) {
|
|
1936
|
-
text = classified.text;
|
|
1937
|
-
} else if (!text) {
|
|
1938
|
-
text = STEERED_ROW_MERGED_NOTE;
|
|
1939
|
-
}
|
|
1940
|
-
await publishFinalStream(steeredMessage, text);
|
|
1941
|
-
await publishResponse(steeredMessage, {
|
|
1942
|
-
text,
|
|
1943
|
-
model: responseModel,
|
|
1944
|
-
...(classified ? { terminalError: terminalErrorRecord(steeredMessage, classified) } : {}),
|
|
1945
|
-
});
|
|
1946
|
-
steered.settle?.();
|
|
1947
|
-
}
|
|
1948
|
-
turn.steeredRows.length = 0;
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
/**
|
|
1952
|
-
* A delivery arrived while a turn was already running.
|
|
1953
|
-
*
|
|
1954
|
-
* The relay's worker socket is single-flight — it will not deliver a second
|
|
1955
|
-
* row until `onDeliver` resolves — so this is not the normal path. It is
|
|
1956
|
-
* reachable on a socket reconnect that redelivers, and it is the path a
|
|
1957
|
-
* future relay change would take, so it steers rather than corrupting the
|
|
1958
|
-
* turn: the prompt is queued into the SAME interaction (which is all
|
|
1959
|
-
* `mode: "enqueue"` can do — it cannot interrupt an in-flight model call),
|
|
1960
|
-
* and the row is adopted so the interaction's terminator settles it too.
|
|
1961
|
-
*
|
|
1962
|
-
* The row is registered BEFORE the send so a failure between the two cannot
|
|
1963
|
-
* leave a row nobody owns.
|
|
3546
|
+
* The entry is registered BEFORE the send so a failure between the two
|
|
3547
|
+
* cannot leave a row nobody owns.
|
|
1964
3548
|
*/
|
|
1965
3549
|
async function steerIntoActiveTurn(turn, message) {
|
|
1966
3550
|
const { prompt: body, attachments } = buildMessageOptionsImpl(message);
|
|
1967
3551
|
// A real relay round trip on the first turn of a mode — long enough for the
|
|
1968
3552
|
// turn to finish underneath us.
|
|
1969
3553
|
const context = await buildRelayContextPrefix(message).catch(() => null);
|
|
1970
|
-
|
|
3554
|
+
// The hidden steer note (shared/steer-note.mjs): a steer that lands before
|
|
3555
|
+
// the model's first output otherwise tempts it to answer only the newest
|
|
3556
|
+
// message and drop the request it was working on (burn-in 2026-09-25).
|
|
3557
|
+
// Not in a continuation: there the running work is the runtime's own
|
|
3558
|
+
// follow-up, not a request of the user's to keep going with.
|
|
3559
|
+
const prompt = withRelayContext(context?.prefix, turn.kind === 'continuation' ? body : withSteerNote(body));
|
|
1971
3560
|
// Re-checked AFTER the awaits and before anything is sent or registered.
|
|
1972
|
-
//
|
|
1973
|
-
//
|
|
1974
|
-
// wedging the single-flight delivery socket. Nothing has been sent yet, so
|
|
1975
|
-
// handing the row back to the normal path is free.
|
|
3561
|
+
// Nothing has been sent yet, so handing the row back to the normal path is
|
|
3562
|
+
// free.
|
|
1976
3563
|
if (turn.settled) {
|
|
1977
3564
|
dbg('turn settled while steering was preparing; running it as a fresh turn', message.id);
|
|
1978
3565
|
return NOT_STEERED;
|
|
1979
3566
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
//
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
turn.promptsSent += 1;
|
|
1987
|
-
const steered = { message, segmentIndex };
|
|
1988
|
-
steered.done = new Promise((resolve) => { steered.settle = resolve; });
|
|
1989
|
-
turn.steeredRows.push(steered);
|
|
1990
|
-
dbg('steering a mid-turn delivery into the running turn', message.id, `segment=${segmentIndex}`);
|
|
3567
|
+
const entry = createEntry(turn, message, 'steer');
|
|
3568
|
+
dbg('steering a mid-turn delivery into the running turn', message.id);
|
|
3569
|
+
let runtimeId;
|
|
3570
|
+
// Counted until send() names the message (see `steerSendsInFlight`).
|
|
3571
|
+
steerSendsInFlight += 1;
|
|
3572
|
+
entry.sendSeq = ++steerSendSeq;
|
|
1991
3573
|
try {
|
|
1992
|
-
await session.send({
|
|
3574
|
+
runtimeId = await session.send({
|
|
1993
3575
|
prompt,
|
|
1994
3576
|
...(attachments?.length ? { attachments } : {}),
|
|
1995
|
-
mode: '
|
|
3577
|
+
mode: 'immediate',
|
|
1996
3578
|
agentMode: copilotAgentModeForRelayMode(message?.relayMode || 'agent'),
|
|
1997
3579
|
});
|
|
1998
3580
|
} catch (error) {
|
|
1999
3581
|
// The prompt never reached the runtime, so nothing will answer it and the
|
|
2000
3582
|
// row is safe to requeue — unlike an accepted one.
|
|
2001
|
-
turn.
|
|
3583
|
+
turn.entries = turn.entries.filter((other) => other !== entry);
|
|
3584
|
+
entry.settled = true;
|
|
3585
|
+
entry.released = true;
|
|
3586
|
+
entry.resolveDone(true);
|
|
3587
|
+
steerSendsInFlight = Math.max(0, steerSendsInFlight - 1);
|
|
2002
3588
|
dbg('steering send failed, requeuing the row', message.id, error?.message || String(error));
|
|
2003
3589
|
await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
|
|
2004
3590
|
return true;
|
|
2005
3591
|
}
|
|
3592
|
+
assignRuntimeId(turn, entry, runtimeId);
|
|
3593
|
+
steerSendsInFlight = Math.max(0, steerSendsInFlight - 1);
|
|
2006
3594
|
// Same commit-after-send rule as `runTurn` (audit #32): a steered prompt
|
|
2007
3595
|
// whose send failed never delivered its guidance, so the retry must
|
|
2008
3596
|
// include it again.
|
|
2009
3597
|
context?.commit();
|
|
2010
|
-
|
|
2011
|
-
|
|
3598
|
+
scheduleQueueLaneRefresh();
|
|
3599
|
+
// Resolves when the interaction settles this row.
|
|
3600
|
+
await awaitEntry(turn, entry);
|
|
3601
|
+
return true;
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
// ------------------------------------------------------------------ gate --
|
|
3605
|
+
|
|
3606
|
+
/**
|
|
3607
|
+
* Whether a message delivered now would steer into the live turn. False
|
|
3608
|
+
* while: no session / no live turn; the turn-opening prompt has not been
|
|
3609
|
+
* sent yet, or a continuation's row does not exist yet (the delivery hold —
|
|
3610
|
+
* a second send before the first would invert the runs, and a steer's run
|
|
3611
|
+
* would settle the continuation's reply against no row while its buffered
|
|
3612
|
+
* output flushed into the steered one); a question card, ask-mode approval
|
|
3613
|
+
* or elicitation is open (the relay must not push past a card); a
|
|
3614
|
+
* compaction is running.
|
|
3615
|
+
*/
|
|
3616
|
+
function canAcceptSteering() {
|
|
3617
|
+
if (disposed || !session) return false;
|
|
3618
|
+
const turn = activeTurn;
|
|
3619
|
+
if (!turn || turn.settled || turn.aborted) return false;
|
|
3620
|
+
if (!turn.primarySent || awaitingRow(turn)) return false;
|
|
3621
|
+
if (pendingHumanRequests > 0) return false;
|
|
3622
|
+
if (isCompacting()) return false;
|
|
3623
|
+
return true;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
/** A continuation whose synthetic row is still being minted (and not given up on). */
|
|
3627
|
+
function awaitingRow(turn) {
|
|
3628
|
+
return turn.kind === 'continuation' && !turn.registered && !turn.discarded;
|
|
3629
|
+
}
|
|
3630
|
+
|
|
3631
|
+
/**
|
|
3632
|
+
* The one steering gate. A delivery arriving now is held — handed back to
|
|
3633
|
+
* the queue instead of pushed — when a turn is live (or a human request /
|
|
3634
|
+
* compaction is open) and it cannot accept a steer. The socket link consults
|
|
3635
|
+
* this for its readiness too, idle or mid-delivery.
|
|
3636
|
+
*/
|
|
3637
|
+
function isDeliveryHeld() {
|
|
3638
|
+
if (disposed) return false;
|
|
3639
|
+
const turn = activeTurn;
|
|
3640
|
+
const live = Boolean(turn && !turn.settled);
|
|
3641
|
+
if (!live && pendingHumanRequests === 0 && !isCompacting()) return false;
|
|
3642
|
+
return !canAcceptSteering();
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3645
|
+
function syncDeliveryReadiness() {
|
|
3646
|
+
const ready = !isDeliveryHeld();
|
|
3647
|
+
if (ready === lastReportedDeliveryReady) return;
|
|
3648
|
+
lastReportedDeliveryReady = ready;
|
|
3649
|
+
try {
|
|
3650
|
+
onDeliveryReadinessChange(ready);
|
|
3651
|
+
} catch (error) {
|
|
3652
|
+
dbg('delivery readiness listener failed', error?.message || String(error));
|
|
3653
|
+
}
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
/**
|
|
3657
|
+
* Give a held delivery back to the queue with no retry penalty. The relay
|
|
3658
|
+
* re-delivers it once this worker signals ready again, i.e. when the hold
|
|
3659
|
+
* ends — the message then steers into the resumed turn. Pushing it into the
|
|
3660
|
+
* hold instead would bypass the question card or land inside a compaction.
|
|
3661
|
+
*/
|
|
3662
|
+
async function handBackHeldDelivery(message) {
|
|
3663
|
+
dbg('steering held; handing the delivery back to the queue', message.id || '(no id)');
|
|
3664
|
+
if (!message.id) return false;
|
|
3665
|
+
await api('POST', '/api/requeue', {
|
|
3666
|
+
messageId: message.id,
|
|
3667
|
+
class: 'steering-held',
|
|
3668
|
+
...attemptFields(message),
|
|
3669
|
+
}).catch((error) => {
|
|
3670
|
+
if (isStaleAttemptError(error)) {
|
|
3671
|
+
dbg('steering-held hand-back refused as stale_attempt', message.id);
|
|
3672
|
+
return;
|
|
3673
|
+
}
|
|
3674
|
+
dbg('steering-held hand-back failed', message.id, error?.message || String(error));
|
|
3675
|
+
});
|
|
3676
|
+
return false;
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
function* ownedEntries() {
|
|
3680
|
+
if (activeTurn) yield* activeTurn.entries;
|
|
3681
|
+
for (const turn of publishingTurns) yield* turn.entries;
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
/**
|
|
3685
|
+
* Pushed rows the client may still cancel: unconsumed and recallable — in
|
|
3686
|
+
* practice the newest waiting steer (immediate sends wait in the runtime's
|
|
3687
|
+
* id-less steering lane), or any row that sits in the queued lane.
|
|
3688
|
+
*/
|
|
3689
|
+
function cancellableIds() {
|
|
3690
|
+
const ids = [];
|
|
3691
|
+
for (const entry of unconsumedSteers()) {
|
|
3692
|
+
if (!recallMethodFor(entry)) continue;
|
|
3693
|
+
const id = String(entry.message?.id || '').trim();
|
|
3694
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
3695
|
+
}
|
|
3696
|
+
return ids;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
/**
|
|
3700
|
+
* Composer-facing steering snapshot, published on the heartbeat: whether a
|
|
3701
|
+
* turn is live, whether a message typed now would steer into it, and — when
|
|
3702
|
+
* it would not — why. `supported` is how the client learns this Copilot
|
|
3703
|
+
* conversation steers at all (its provider type alone cannot tell an SDK
|
|
3704
|
+
* worker from the extension engine); `cancellableIds` are the pushed rows
|
|
3705
|
+
* that may still be pulled back out of the runtime (decision 8).
|
|
3706
|
+
*/
|
|
3707
|
+
function steeringState() {
|
|
3708
|
+
const turn = activeTurn;
|
|
3709
|
+
const turnActive = Boolean(turn && !turn.settled);
|
|
3710
|
+
const canSteer = canAcceptSteering();
|
|
3711
|
+
let holdReason = null;
|
|
3712
|
+
if (turnActive && !canSteer) {
|
|
3713
|
+
if (pendingHumanRequests > 0) holdReason = 'question';
|
|
3714
|
+
else if (isCompacting()) holdReason = 'compaction';
|
|
3715
|
+
else if (!turn.primarySent || awaitingRow(turn)) holdReason = 'delivery';
|
|
3716
|
+
else holdReason = 'other';
|
|
3717
|
+
}
|
|
3718
|
+
const messageId = String(turn?.currentOwner?.message?.id || turn?.message?.id || '').trim() || null;
|
|
3719
|
+
return { turnActive, canSteer, holdReason, messageId, supported: true, cancellableIds: cancellableIds() };
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
/**
|
|
3723
|
+
* Re-read the queue, and — only if the prompt is still recallable at that
|
|
3724
|
+
* instant — issue its remove request in the same tick as the check, so no
|
|
3725
|
+
* steer send can be issued in between (JSON-RPC keeps request order).
|
|
3726
|
+
*/
|
|
3727
|
+
async function recallPushedPrompt(entry, id) {
|
|
3728
|
+
// Watched from before the read, so a fold that lands during the read or
|
|
3729
|
+
// the remove is seen however far behind the dispatch chain runs.
|
|
3730
|
+
const watch = { taken: false };
|
|
3731
|
+
recallWatches.set(entry.runtimeId, watch);
|
|
3732
|
+
try {
|
|
3733
|
+
// Only the READ is bounded: a runtime that will not answer it gets no
|
|
3734
|
+
// remove at all. Once a remove is sent it is seen through to its answer —
|
|
3735
|
+
// giving up on it could leave the prompt removed and the row uncancelled.
|
|
3736
|
+
const fresh = await withTimeout(refreshQueueLane().then(() => true), UNSTEER_TIMEOUT_MS, false);
|
|
3737
|
+
if (!fresh) {
|
|
3738
|
+
dbg('un-steer refused: the runtime did not answer the queue read in time', id);
|
|
3739
|
+
return false;
|
|
3740
|
+
}
|
|
3741
|
+
const target = session;
|
|
3742
|
+
const method = watch.taken ? null : recallMethodFor(entry);
|
|
3743
|
+
if (!target || !method) {
|
|
3744
|
+
dbg('un-steer refused: the runtime no longer holds this prompt as recallable', id);
|
|
3745
|
+
return false;
|
|
3746
|
+
}
|
|
3747
|
+
const request = method.kind === 'queued'
|
|
3748
|
+
? target.rpc.queue.removeAt({ id: method.itemId })
|
|
3749
|
+
: target.rpc.queue.removeMostRecent();
|
|
3750
|
+
const removed = (await request)?.removed === true;
|
|
3751
|
+
if (removed && (watch.taken || entry.consumed || entry.settled)) {
|
|
3752
|
+
// The runtime took this prompt before it processed the remove, so
|
|
3753
|
+
// what was removed is some other waiting message — another client's,
|
|
3754
|
+
// never a runtime notification (see `recallWatches`). It cannot be put
|
|
3755
|
+
// back; this row settles through its normal path.
|
|
3756
|
+
dbg('UN-STEER MISMATCH: the runtime took the prompt before the remove; the remove took another message', id, method.kind);
|
|
3757
|
+
return false;
|
|
3758
|
+
}
|
|
3759
|
+
return removed;
|
|
3760
|
+
} finally {
|
|
3761
|
+
if (recallWatches.get(entry.runtimeId) === watch) recallWatches.delete(entry.runtimeId);
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
function withTimeout(promise, ms, fallback) {
|
|
3766
|
+
let timer = null;
|
|
3767
|
+
return Promise.race([
|
|
3768
|
+
promise,
|
|
3769
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(fallback), ms); timer.unref?.(); }),
|
|
3770
|
+
]).finally(() => clearTimeout(timer));
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
/**
|
|
3774
|
+
* Un-steer (decision 8, narrowed 2026-09-25 after the live check): pull a
|
|
3775
|
+
* pushed-but-unconsumed row back out of the runtime and cancel it. Immediate
|
|
3776
|
+
* sends wait in the runtime's id-less steering lane, so only the NEWEST
|
|
3777
|
+
* waiting one is recallable (`queue.removeMostRecent`); a row in the queued
|
|
3778
|
+
* lane goes by its stable id. A prompt the runtime already took cannot be
|
|
3779
|
+
* recalled; the control is then a no-op and the row settles normally.
|
|
3780
|
+
*/
|
|
3781
|
+
async function cancelPushedMessage(messageId) {
|
|
3782
|
+
const id = String(messageId || '').trim();
|
|
3783
|
+
if (!id) return false;
|
|
3784
|
+
let entry = null;
|
|
3785
|
+
for (const candidate of ownedEntries()) {
|
|
3786
|
+
if (String(candidate.message?.id || '') === id) { entry = candidate; break; }
|
|
3787
|
+
}
|
|
3788
|
+
if (!entry || entry.consumed || entry.settled || !entry.runtimeId) {
|
|
3789
|
+
dbg('un-steer refused: row not cancellable', id, entry ? `consumed=${entry.consumed} settled=${entry.settled}` : 'unknown row');
|
|
3790
|
+
return false;
|
|
3791
|
+
}
|
|
3792
|
+
if (!session || !sessionRpc.queue) return false;
|
|
3793
|
+
// One un-steer at a time; each re-reads the queue (that read is bounded —
|
|
3794
|
+
// see recallPushedPrompt). Steer sends never wait on this chain.
|
|
3795
|
+
const run = unsteerChain.then(() => recallPushedPrompt(entry, id));
|
|
3796
|
+
unsteerChain = run.catch(() => false);
|
|
3797
|
+
const removed = await run.catch((error) => {
|
|
3798
|
+
dbg('un-steer failed', id, error?.message || String(error));
|
|
3799
|
+
return false;
|
|
3800
|
+
});
|
|
3801
|
+
if (!removed) return false;
|
|
3802
|
+
// Settled meanwhile by its turn's end, or taken since the answer (the
|
|
3803
|
+
// remove then took another message): it keeps its normal outcome.
|
|
3804
|
+
if (entry.consumed || entry.settled) {
|
|
3805
|
+
dbg('un-steer raced the runtime taking the prompt or the row settling; leaving it to settle normally', id);
|
|
3806
|
+
return false;
|
|
3807
|
+
}
|
|
3808
|
+
entry.cancelled = true;
|
|
3809
|
+
entry.settled = true;
|
|
3810
|
+
while (recalledRuntimeIds.size >= MAX_RECALLED_RUNTIME_IDS) recalledRuntimeIds.delete(recalledRuntimeIds.keys().next().value);
|
|
3811
|
+
recalledRuntimeIds.set(entry.runtimeId, id);
|
|
3812
|
+
dbg('un-steered a pushed prompt', id);
|
|
3813
|
+
// The prompt is gone from the runtime; the row must end cancelled and
|
|
3814
|
+
// never be re-delivered. It stays owned (settling, with the terminal
|
|
3815
|
+
// wording the crash guard would send) until the relay acknowledges the
|
|
3816
|
+
// cancel; if the relay never does, the heartbeat hands it over to fail.
|
|
3817
|
+
claimSettling(entry.message, 'cancelled');
|
|
3818
|
+
const cancelBody = {
|
|
3819
|
+
conversationId: entry.message.conversationId,
|
|
3820
|
+
messageId: id,
|
|
3821
|
+
...attemptFields(entry.message),
|
|
3822
|
+
};
|
|
3823
|
+
let acknowledged = false;
|
|
3824
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
3825
|
+
acknowledged = await api('POST', '/api/queue-cancelled', cancelBody).then(() => true, (error) => {
|
|
3826
|
+
dbg('queue-cancelled publish failed', id, error?.message || String(error));
|
|
3827
|
+
// The row already left `processing` (answered, recovered, or already
|
|
3828
|
+
// cancelled): nothing left for this worker to do.
|
|
3829
|
+
return Number(error?.status) === 404 || Number(error?.status) === 409;
|
|
3830
|
+
});
|
|
3831
|
+
if (acknowledged || attempt >= settleRetryDelaysMs.length) break;
|
|
3832
|
+
await sleep(settleRetryDelaysMs[attempt]);
|
|
3833
|
+
}
|
|
3834
|
+
if (!acknowledged) {
|
|
3835
|
+
dbg('queue-cancelled could not be posted; handing the row to the heartbeat', id);
|
|
3836
|
+
settleFailedTerminals.set(id, buildSteerSettleFailure(entry.message, { variant: 'cancelled', agentLabel: SETTLE_AGENT_LABEL }));
|
|
3837
|
+
}
|
|
3838
|
+
releaseSettling(id);
|
|
3839
|
+
entry.released = true;
|
|
3840
|
+
entry.resolveDone(true);
|
|
3841
|
+
const turn = entry.turn;
|
|
3842
|
+
if (turn) {
|
|
3843
|
+
turn.entries = turn.entries.filter((other) => other !== entry);
|
|
3844
|
+
turn.pushed.delete(entry.runtimeId);
|
|
3845
|
+
}
|
|
3846
|
+
scheduleQueueLaneRefresh();
|
|
2012
3847
|
return true;
|
|
2013
3848
|
}
|
|
2014
3849
|
|
|
2015
3850
|
async function handlePendingPayload(pending) {
|
|
2016
3851
|
const message = pending?.message || null;
|
|
2017
3852
|
if (!message) return false;
|
|
3853
|
+
// Held (a card is open, a compaction is running, the turn-opening prompt
|
|
3854
|
+
// is not sent yet): hand the row back with no retry penalty; the relay
|
|
3855
|
+
// re-delivers it when the hold ends. An older relay (the worker updated on
|
|
3856
|
+
// disk before the relay restarted) treats the hand-back as a failed
|
|
3857
|
+
// delivery — retry, backoff, worker marked errored — so against one the
|
|
3858
|
+
// message is pushed the legacy way.
|
|
3859
|
+
if (isDeliveryHeld() && canHandBackHeldDelivery() !== false) return handBackHeldDelivery(message);
|
|
3860
|
+
// Legacy path only: never send a second prompt before the turn-opening one
|
|
3861
|
+
// (it would invert the runs), and never start a second turn beside one
|
|
3862
|
+
// that is still connecting.
|
|
3863
|
+
if (activeTurn && !activeTurn.settled && !activeTurn.primarySent) {
|
|
3864
|
+
await activeTurn.primaryReady;
|
|
3865
|
+
}
|
|
3866
|
+
// Nor steer into a continuation before its row exists (canAcceptSteering);
|
|
3867
|
+
// bounded like the registration itself, which can hang on a dead relay.
|
|
3868
|
+
if (activeTurn && !activeTurn.settled && awaitingRow(activeTurn)) {
|
|
3869
|
+
await withTimeout(activeTurn.rowReady, continuationRegistrationTimeoutMs, false);
|
|
3870
|
+
}
|
|
2018
3871
|
// A delivery that lands while a turn is running is steered into it rather
|
|
2019
3872
|
// than starting a second one: the runtime has a single conversation and a
|
|
2020
3873
|
// concurrent `send` would interleave into the same interaction anyway —
|
|
2021
3874
|
// this way the row is owned and settled instead of orphaned.
|
|
2022
|
-
//
|
|
2023
|
-
// Deliberately OUTSIDE the try/finally below: that `finally` clears
|
|
2024
|
-
// `activeTurn`, and a steered call returns while the turn it was steered
|
|
2025
|
-
// into is still publishing. Clearing there would tell the heartbeat this
|
|
2026
|
-
// worker owns nothing and the relay would recover the live row.
|
|
2027
3875
|
if (activeTurn && !activeTurn.settled && session) {
|
|
2028
3876
|
const turn = activeTurn;
|
|
2029
3877
|
try {
|
|
@@ -2040,75 +3888,30 @@ export function createCopilotSdkSessionRunner({
|
|
|
2040
3888
|
return true;
|
|
2041
3889
|
}
|
|
2042
3890
|
}
|
|
2043
|
-
// Created here rather than inside `runTurn` so the catch and finally below
|
|
2044
|
-
// act on THIS turn by identity: under split ownership, `activeTurn` may
|
|
2045
|
-
// already belong to a newer turn (a continuation the runtime opened while
|
|
2046
|
-
// this one was publishing) by the time they run.
|
|
2047
3891
|
const turn = createTurn(message);
|
|
2048
|
-
|
|
2049
|
-
return await runTurn(turn);
|
|
2050
|
-
} catch (error) {
|
|
2051
|
-
const classified = classifyCopilotTurnException(error);
|
|
2052
|
-
dbg('copilot turn failed', message.id, classified.detail);
|
|
2053
|
-
// A failure that killed the session (or came from starting it) leaves a
|
|
2054
|
-
// handle nothing else can use; drop it so the next delivery rebuilds and
|
|
2055
|
-
// resumes rather than sending into a dead runtime. An UNCONFIRMED MODEL
|
|
2056
|
-
// SWITCH is the exception: the session is healthy and merely still on
|
|
2057
|
-
// its previous model, and tearing the runtime down over it would kill
|
|
2058
|
-
// any live detached shells for a selection problem the user fixes by
|
|
2059
|
-
// picking another model.
|
|
2060
|
-
if (!isModelSwitchUnconfirmedError(error)) {
|
|
2061
|
-
await stopRuntime('turn-failure').catch(() => {});
|
|
2062
|
-
}
|
|
2063
|
-
await publishResponse(message, {
|
|
2064
|
-
text: classified.text,
|
|
2065
|
-
model: null,
|
|
2066
|
-
terminalError: terminalErrorRecord(message, classified),
|
|
2067
|
-
});
|
|
2068
|
-
// Rows steered into the turn that just threw are owed a response too —
|
|
2069
|
-
// and, more urgently, `steerIntoActiveTurn` is still awaiting their
|
|
2070
|
-
// settle. Skipping this would wedge that caller (and its queue row)
|
|
2071
|
-
// forever behind a heartbeat that keeps renewing the lease.
|
|
2072
|
-
await settleSteeredRows(turn, null, null, classified).catch(() => {});
|
|
2073
|
-
await closeStraySubagentRuns(turn).catch(() => {});
|
|
2074
|
-
return true;
|
|
2075
|
-
} finally {
|
|
2076
|
-
// Released only once every publish for this row has landed. A heartbeat
|
|
2077
|
-
// that fired during the publish window with the row unowned would tell
|
|
2078
|
-
// the relay this worker owns nothing, and the still-processing row would
|
|
2079
|
-
// be recovered (`owner-heartbeat-idle`) and re-delivered — a duplicate
|
|
2080
|
-
// execution racing the response that was already on its way.
|
|
2081
|
-
releaseTurnOwnership(turn);
|
|
2082
|
-
touch();
|
|
2083
|
-
// Every path through the turn — published, failed, aborted, threw — has
|
|
2084
|
-
// finished by here, which is the only safe place for the ingest: it is
|
|
2085
|
-
// advisory, it is not awaited, and it must never be able to delay a reply
|
|
2086
|
-
// that is already written.
|
|
2087
|
-
postTurnUsage();
|
|
2088
|
-
}
|
|
3892
|
+
return runTurn(turn);
|
|
2089
3893
|
}
|
|
2090
3894
|
|
|
2091
3895
|
// --------------------------------------------------------------- teardown --
|
|
2092
3896
|
|
|
2093
3897
|
function getActiveQueueMessageId() {
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
const id = String(turn.message?.id || '');
|
|
2100
|
-
if (id) return id;
|
|
3898
|
+
const live = activeTurn?.currentOwner;
|
|
3899
|
+
if (live && !live.released && live.message?.id) return String(live.message.id);
|
|
3900
|
+
for (const entry of ownedEntries()) {
|
|
3901
|
+
const id = String(entry.message?.id || '');
|
|
3902
|
+
if (id && !entry.released) return id;
|
|
2101
3903
|
}
|
|
2102
3904
|
return '';
|
|
2103
3905
|
}
|
|
2104
3906
|
|
|
2105
3907
|
/**
|
|
2106
|
-
* Every row this worker owns — the running turn's
|
|
2107
|
-
*
|
|
2108
|
-
*
|
|
2109
|
-
*
|
|
2110
|
-
*
|
|
2111
|
-
*
|
|
3908
|
+
* Every row this worker owns — the running turn's rows and the same for
|
|
3909
|
+
* every settled turn still publishing — as `{ id, attemptId }` entries; a
|
|
3910
|
+
* consumed row whose settle marker is still being saved carries the
|
|
3911
|
+
* terminal failure the crash guard must send instead of a requeue, because
|
|
3912
|
+
* its prompt must never run twice. A row missing from this list would be
|
|
3913
|
+
* recovered mid-flight and re-delivered while the runtime was still
|
|
3914
|
+
* answering it.
|
|
2112
3915
|
*
|
|
2113
3916
|
* Both the heartbeat (lease renewal) and the crash guard (requeue-on-exit)
|
|
2114
3917
|
* read this: the crash guard takes the entries whole so its requeues stay
|
|
@@ -2117,24 +3920,36 @@ export function createCopilotSdkSessionRunner({
|
|
|
2117
3920
|
*/
|
|
2118
3921
|
function getActiveQueueMessageIds() {
|
|
2119
3922
|
const entries = [];
|
|
2120
|
-
const
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
3923
|
+
const byId = new Map();
|
|
3924
|
+
const push = (message, terminalError = null) => {
|
|
3925
|
+
const id = String(message?.id || '').trim();
|
|
3926
|
+
if (!id) return;
|
|
3927
|
+
const existing = byId.get(id);
|
|
3928
|
+
if (existing) {
|
|
3929
|
+
if (terminalError && !existing.terminalError) existing.terminalError = terminalError;
|
|
3930
|
+
return;
|
|
3931
|
+
}
|
|
3932
|
+
const entry = { id, attemptId: message?.attemptId || null, ...(terminalError ? { terminalError } : {}) };
|
|
3933
|
+
byId.set(id, entry);
|
|
3934
|
+
entries.push(entry);
|
|
2129
3935
|
};
|
|
2130
|
-
|
|
2131
|
-
|
|
3936
|
+
for (const entry of ownedEntries()) {
|
|
3937
|
+
if (entry.released) continue;
|
|
3938
|
+
push(entry.message);
|
|
3939
|
+
}
|
|
3940
|
+
for (const { message, variant } of settlingMessages.values()) {
|
|
3941
|
+
push(message, buildSteerSettleFailure(message, { variant, agentLabel: SETTLE_AGENT_LABEL }));
|
|
3942
|
+
}
|
|
2132
3943
|
return entries;
|
|
2133
3944
|
}
|
|
2134
3945
|
|
|
2135
3946
|
async function dispose() {
|
|
2136
3947
|
disposed = true;
|
|
2137
3948
|
stopLifecycleTimer();
|
|
3949
|
+
if (queueLaneTimer) {
|
|
3950
|
+
clearTimeout(queueLaneTimer);
|
|
3951
|
+
queueLaneTimer = null;
|
|
3952
|
+
}
|
|
2138
3953
|
// A question card left `pending` would sit in the UI inviting an answer
|
|
2139
3954
|
// that nothing is left to read. Time them out before the socket goes.
|
|
2140
3955
|
await questionBridge.cancelPendingQuestions?.().catch?.(() => {});
|
|
@@ -2145,6 +3960,18 @@ export function createCopilotSdkSessionRunner({
|
|
|
2145
3960
|
handlePendingPayload,
|
|
2146
3961
|
getActiveQueueMessageId,
|
|
2147
3962
|
getActiveQueueMessageIds,
|
|
3963
|
+
// The delivery gate (the socket link's probes) and the composer snapshot
|
|
3964
|
+
// (the heartbeat's).
|
|
3965
|
+
canAcceptSteering,
|
|
3966
|
+
isDeliveryHeld,
|
|
3967
|
+
steeringState,
|
|
3968
|
+
getSettleFailed,
|
|
3969
|
+
acknowledgeSettleFailed,
|
|
3970
|
+
cancelPushedMessage,
|
|
3971
|
+
// The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
|
|
3972
|
+
// subagent lane's Stop tries — the runtime tracks sync agents in the same
|
|
3973
|
+
// registry and refuses ids it does not know.
|
|
3974
|
+
stopBackgroundTask,
|
|
2148
3975
|
// "Active" spans both ownerships: a settled turn still publishing must
|
|
2149
3976
|
// keep the worker's idle/shutdown gates closed just like a running one.
|
|
2150
3977
|
isTurnActive: () => !!activeTurn || publishingTurns.size > 0,
|
|
@@ -2157,6 +3984,14 @@ export function createCopilotSdkSessionRunner({
|
|
|
2157
3984
|
whenUsagePosted: () => usagePostChain,
|
|
2158
3985
|
// The in-flight model-catalog snapshot POST — the same kind of seam.
|
|
2159
3986
|
whenModelSnapshotPosted: () => modelSnapshotChain,
|
|
3987
|
+
// The in-flight queued-lane read — same kind of seam.
|
|
3988
|
+
whenQueueLaneRefreshed: () => queueLaneRefreshChain,
|
|
3989
|
+
// The in-flight `rpc.tasks.list()` re-read — same kind of seam. A refresh
|
|
3990
|
+
// that is still debounced counts too, so a caller sees the registry read
|
|
3991
|
+
// that the last event scheduled.
|
|
3992
|
+
whenTasksRefreshed: () => (taskRefreshTimer
|
|
3993
|
+
? new Promise((resolve) => { setTimeout(resolve, taskRefreshMs + 5); }).then(() => taskRefreshChain)
|
|
3994
|
+
: taskRefreshChain),
|
|
2160
3995
|
// Test seams / observability.
|
|
2161
3996
|
_getState: () => ({
|
|
2162
3997
|
hasClient: !!client,
|
|
@@ -2171,7 +4006,23 @@ export function createCopilotSdkSessionRunner({
|
|
|
2171
4006
|
// Settled turns whose relay publishes have not all landed yet.
|
|
2172
4007
|
publishingTurnCount: publishingTurns.size,
|
|
2173
4008
|
backgroundShells: backgroundShells.live(),
|
|
4009
|
+
backgroundTasks: sessionRpc.tasks ? registryCards() : shellTrackerCards(),
|
|
2174
4010
|
continuationDueSince,
|
|
4011
|
+
compacting: isCompacting(),
|
|
4012
|
+
pendingHumanRequests,
|
|
4013
|
+
pendingRelayToolCalls,
|
|
4014
|
+
sessionRpc: { ...sessionRpc },
|
|
4015
|
+
activeEntries: activeTurn
|
|
4016
|
+
? activeTurn.entries.map((entry) => ({
|
|
4017
|
+
id: entry.message?.id || null,
|
|
4018
|
+
role: entry.role,
|
|
4019
|
+
runtimeId: entry.runtimeId,
|
|
4020
|
+
consumed: entry.consumed,
|
|
4021
|
+
delivery: entry.delivery,
|
|
4022
|
+
settled: entry.settled,
|
|
4023
|
+
foldedInto: entry.foldedInto?.message?.id || null,
|
|
4024
|
+
}))
|
|
4025
|
+
: [],
|
|
2175
4026
|
}),
|
|
2176
4027
|
_evaluateLifecycle: evaluateLifecycle,
|
|
2177
4028
|
};
|