@oar-sh/oar 0.9.0
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/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- package/shared/worker-crash-guard.mjs +60 -0
|
@@ -0,0 +1,1729 @@
|
|
|
1
|
+
// Owns the CopilotClient / CopilotSession lifecycle for one relay conversation
|
|
2
|
+
// and runs the delivered turns through them.
|
|
3
|
+
//
|
|
4
|
+
// Contract-wise this is the Cursor/Claude worker's `handlePendingPayload`
|
|
5
|
+
// runner with a different engine underneath: same relay channels, same
|
|
6
|
+
// response/requeue/abort semantics, same terminal-error record shape. The
|
|
7
|
+
// engine-specific part is that the Copilot SDK does not hand back an async
|
|
8
|
+
// iterator for a turn — `send()` resolves as soon as the prompt is accepted —
|
|
9
|
+
// so the turn is driven as a small state machine over the session's event
|
|
10
|
+
// callback. `sendAndWait()` is deliberately unused: it has a hard 60s internal
|
|
11
|
+
// timeout after which it merely stops waiting, which would silently strand
|
|
12
|
+
// every long turn.
|
|
13
|
+
//
|
|
14
|
+
// ## Self-initiated turns
|
|
15
|
+
//
|
|
16
|
+
// The runtime does not only answer prompts. A detached background shell
|
|
17
|
+
// (`bash{mode:"async", detach:true}`) settles on its own clock, and the runtime
|
|
18
|
+
// then re-invokes the model with NO prompt behind it — a `system.notification`
|
|
19
|
+
// followed by a fresh `assistant.turn_start`, tool calls and a durable
|
|
20
|
+
// `assistant.message`. Live burn-in (session `10a1a9ad`, 2026-08-31: "set a
|
|
21
|
+
// timer to 1 minute") caught the whole of that second turn being dropped
|
|
22
|
+
// because no relay row was open to publish it into, and the user never saw the
|
|
23
|
+
// answer they had been promised.
|
|
24
|
+
//
|
|
25
|
+
// So a turn here is one of two kinds:
|
|
26
|
+
//
|
|
27
|
+
// - `delivered` — a queue row arrived, `runTurn` sends its prompt;
|
|
28
|
+
// - `continuation` — the runtime started work by itself. The worker mints a
|
|
29
|
+
// synthetic queue row (`POST /api/continuation-turn`) and runs the SAME
|
|
30
|
+
// state machine over the events, so the turn gets the full relay surface:
|
|
31
|
+
// stream, thoughts, activity, questions, usage and a response of its own.
|
|
32
|
+
// Actions produced before the row exists are buffered and flushed in order.
|
|
33
|
+
//
|
|
34
|
+
// Three rules keep that safe, and each is enforced in `routeEvent`:
|
|
35
|
+
//
|
|
36
|
+
// 1. **Replay is not new work.** `session.resume` can replay persisted history
|
|
37
|
+
// through the same callback; `createReplayGate` drops it (see that module
|
|
38
|
+
// for why `resumeTime` and `eventCount` are used together).
|
|
39
|
+
// 2. **Liveness pins the runtime.** Idle shutdown must not stop a runtime that
|
|
40
|
+
// has a detached shell running or a settled shell's continuation still due
|
|
41
|
+
// — stopping it kills the shell. `createBackgroundShellTracker` supplies
|
|
42
|
+
// the set; `backgroundTaskTimeoutMs` caps how long it may pin.
|
|
43
|
+
// 3. **A continuation is a turn like any other.** It ends on `session.idle`,
|
|
44
|
+
// its usage is captured and posted, and a user message delivered while it
|
|
45
|
+
// runs is steered into it (answered from its own prompt segment) rather
|
|
46
|
+
// than cross-published into the continuation's row.
|
|
47
|
+
import {
|
|
48
|
+
USER_INPUT_UNSUPPORTED_ANSWER,
|
|
49
|
+
classifyCopilotSessionError,
|
|
50
|
+
classifyCopilotTurnException,
|
|
51
|
+
copilotAgentModeForRelayMode,
|
|
52
|
+
createCopilotPermissionHandler,
|
|
53
|
+
isReadOnlyPermissionRequest,
|
|
54
|
+
isSessionNotFoundError,
|
|
55
|
+
observeRuntimeExit,
|
|
56
|
+
resolveCopilotSdkPaths,
|
|
57
|
+
startCopilotClient,
|
|
58
|
+
} from './copilot-sdk-adapter.mjs';
|
|
59
|
+
import { buildCopilotMessageOptions } from './copilot-attachments.mjs';
|
|
60
|
+
import { resolveCopilotProviderConfig } from './copilot-byok-provider.mjs';
|
|
61
|
+
import {
|
|
62
|
+
createBackgroundShellTracker,
|
|
63
|
+
createReplayGate,
|
|
64
|
+
describeSettledShell,
|
|
65
|
+
isContinuationOpeningEvent,
|
|
66
|
+
} from './copilot-continuation-signals.mjs';
|
|
67
|
+
import { createCopilotEventNormalizer } from './copilot-sdk-event-normalizer.mjs';
|
|
68
|
+
import { createCopilotQuestionBridge } from './copilot-question-bridge.mjs';
|
|
69
|
+
import {
|
|
70
|
+
EXIT_PLAN_BOARD_POSTED_FEEDBACK,
|
|
71
|
+
EXIT_PLAN_NO_BOARD_FEEDBACK,
|
|
72
|
+
buildCopilotPlanReadyBoardPayload,
|
|
73
|
+
planTextFromExitRequest,
|
|
74
|
+
shouldPostPlanBoard,
|
|
75
|
+
} from './copilot-plan-board.mjs';
|
|
76
|
+
import {
|
|
77
|
+
createCopilotPromptContextBuilder,
|
|
78
|
+
createPreviewInstructionsProvider,
|
|
79
|
+
loadDefaultRelayToolInstructions,
|
|
80
|
+
withRelayContext,
|
|
81
|
+
} from './copilot-prompt-context.mjs';
|
|
82
|
+
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
83
|
+
|
|
84
|
+
// How long the runtime may sit with no session activity before the worker
|
|
85
|
+
// closes it. The worker process itself stays up and reconnects lazily on the
|
|
86
|
+
// next delivery — same trade the Claude worker makes (`gracefulShutdown('idle')`
|
|
87
|
+
// ends the CLI, not the worker), because holding the ws link is cheap while
|
|
88
|
+
// holding a runtime subprocess per idle conversation is not.
|
|
89
|
+
const DEFAULT_IDLE_SHUTDOWN_MS = 10 * 60_000;
|
|
90
|
+
const DEFAULT_LIFECYCLE_POLL_MS = 5_000;
|
|
91
|
+
// Mirrors the Cursor adapter's `stallTimeoutMs`. Emphatically NOT 0: the
|
|
92
|
+
// worker's 10s heartbeat keeps renewing the relay's processing lease
|
|
93
|
+
// (messages-routes.mjs), so without a stall ceiling a turn whose runtime went
|
|
94
|
+
// quiet holds its queue row open indefinitely and no watchdog can free it.
|
|
95
|
+
const DEFAULT_TURN_STALL_TIMEOUT_MS = 120_000;
|
|
96
|
+
/**
|
|
97
|
+
* How long live background shells ALONE may keep the runtime up (0 = no limit).
|
|
98
|
+
*
|
|
99
|
+
* Deliberately not the relay's `background_task_timeout_minutes` slider, whose
|
|
100
|
+
* default is 0/unlimited: that slider governs Claude's background tasks, which
|
|
101
|
+
* have ids, a composer panel and a stop button, so "no limit" there is a choice
|
|
102
|
+
* the user can see and undo. A Copilot detached shell has none of that — the
|
|
103
|
+
* runtime exposes no RPC to stop one and the relay has no surface listing them
|
|
104
|
+
* — so an unlimited default would let a single forgotten `sleep 99999` pin a
|
|
105
|
+
* runtime subprocess for the life of the relay with nothing to point at. 30
|
|
106
|
+
* minutes is well past any timer a user would sit and wait for, and the cap
|
|
107
|
+
* only ever costs the shell, never a turn.
|
|
108
|
+
*/
|
|
109
|
+
const DEFAULT_BACKGROUND_TASK_TIMEOUT_MS = 30 * 60_000;
|
|
110
|
+
/**
|
|
111
|
+
* How long after a shell settles the runtime is held up waiting for the
|
|
112
|
+
* continuation it should trigger.
|
|
113
|
+
*
|
|
114
|
+
* The live capture had 3ms between the `system.notification` and the
|
|
115
|
+
* `assistant.turn_start`. This window only has to survive a runtime that
|
|
116
|
+
* notifies and then decides there is nothing to say — mirrors the Claude
|
|
117
|
+
* worker's `notificationGraceMs`, same value.
|
|
118
|
+
*/
|
|
119
|
+
const DEFAULT_CONTINUATION_GRACE_MS = 60_000;
|
|
120
|
+
/** Retry spacing for the synthetic-row registration (3 attempts). */
|
|
121
|
+
const DEFAULT_CONTINUATION_RETRY_DELAY_MS = 500;
|
|
122
|
+
/** How long a continuation's actions may buffer before its row is abandoned. */
|
|
123
|
+
const CONTINUATION_REGISTRATION_TIMEOUT_MS = 10_000;
|
|
124
|
+
/** Cap on activity lines carried between turns, so a chatty runtime cannot grow them. */
|
|
125
|
+
const MAX_PENDING_ACTIVITIES = 20;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Appended to the partial answer when the RUNTIME interrupted the turn on its
|
|
129
|
+
* own (as opposed to the user aborting through the relay). The row has to be
|
|
130
|
+
* settled by this worker in that case, because nothing server-side is waiting
|
|
131
|
+
* to settle it.
|
|
132
|
+
*/
|
|
133
|
+
export const RUNTIME_INTERRUPTED_NOTE =
|
|
134
|
+
'System note: the Copilot runtime interrupted this turn before it finished. '
|
|
135
|
+
+ 'Resend the message to continue.';
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Published to a steered queue row whose prompt the runtime accepted but never
|
|
139
|
+
* opened work on before the interaction ended. The prompt is still queued
|
|
140
|
+
* INSIDE the runtime, so it will be answered at the start of the next turn —
|
|
141
|
+
* requeuing the row would run it twice. Mirrors the Claude worker's
|
|
142
|
+
* handed-off-context note, which exists for the same reason.
|
|
143
|
+
*/
|
|
144
|
+
export const STEERED_ROW_MERGED_NOTE =
|
|
145
|
+
'_(This message was delivered while the previous turn was still running; the reply continues in '
|
|
146
|
+
+ 'the next turn.)_';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The `trigger` reported to `POST /api/continuation-turn`, matching the value
|
|
150
|
+
* the Claude worker sends so the relay's `CONTINUATION … trigger=` log line and
|
|
151
|
+
* any future per-trigger handling read the same for both engines.
|
|
152
|
+
*/
|
|
153
|
+
export const CONTINUATION_TRIGGER = 'background_task';
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Compaction / infinite-session policy.
|
|
157
|
+
*
|
|
158
|
+
* These are the runtime's OWN documented defaults for `InfiniteSessionConfig`
|
|
159
|
+
* (enabled, background compaction at 0.80 of the context window, blocking
|
|
160
|
+
* compaction at 0.95) — they are set explicitly rather than left unset so a
|
|
161
|
+
* future change to the runtime's defaults cannot silently move the point at
|
|
162
|
+
* which a long relay conversation starts compacting. Compaction is what makes
|
|
163
|
+
* a resumable, long-lived relay conversation possible at all: the alternative
|
|
164
|
+
* is a turn that fails on context overflow with the whole history intact and
|
|
165
|
+
* no way forward.
|
|
166
|
+
*/
|
|
167
|
+
export const DEFAULT_INFINITE_SESSION_CONFIG = Object.freeze({
|
|
168
|
+
enabled: true,
|
|
169
|
+
backgroundCompactionThreshold: 0.8,
|
|
170
|
+
bufferExhaustionThreshold: 0.95,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
/** `steerIntoActiveTurn` could not adopt the row; run it as a normal turn. */
|
|
174
|
+
const NOT_STEERED = Symbol('not-steered');
|
|
175
|
+
|
|
176
|
+
export function createCopilotSdkSessionRunner({
|
|
177
|
+
api,
|
|
178
|
+
sdkSessionId,
|
|
179
|
+
cwd,
|
|
180
|
+
defaultModel = '',
|
|
181
|
+
controlPoller = null,
|
|
182
|
+
env = process.env,
|
|
183
|
+
clientName = 'copilot-web-relay',
|
|
184
|
+
logLevel = 'error',
|
|
185
|
+
// Injection seams. Tests pass a fake client/session pair; nothing in this
|
|
186
|
+
// module imports the real SDK (that lives in copilot-sdk-adapter.mjs).
|
|
187
|
+
resolvePathsImpl = resolveCopilotSdkPaths,
|
|
188
|
+
startClientImpl = startCopilotClient,
|
|
189
|
+
createNormalizerImpl = createCopilotEventNormalizer,
|
|
190
|
+
buildMessageOptionsImpl = buildCopilotMessageOptions,
|
|
191
|
+
// BYOK: `COPILOT_PROVIDER_*` in this worker's env become
|
|
192
|
+
// `SessionConfig.provider`. Injected so tests can drive the branch without
|
|
193
|
+
// mutating process.env.
|
|
194
|
+
resolveProviderConfigImpl = resolveCopilotProviderConfig,
|
|
195
|
+
// Threading seam for `MessageOptions.mode` ("enqueue" | "immediate").
|
|
196
|
+
//
|
|
197
|
+
// A BYOK probe against runtime 1.0.82 ran a mid-turn send three ways —
|
|
198
|
+
// "enqueue", "immediate" and unset — and all three behaved IDENTICALLY: the
|
|
199
|
+
// send resolves in ~2ms with a message id, the prompt is queued
|
|
200
|
+
// (`pending_messages.modified`), the in-flight model call runs to completion
|
|
201
|
+
// untouched, and the prompt is picked up at the NEXT model-call boundary with
|
|
202
|
+
// its own `user.message`. Neither mode interrupts anything. "enqueue" is the
|
|
203
|
+
// documented default and preserves FIFO order, which is what the relay queue
|
|
204
|
+
// contract wants, so it is what this sends. See §5 of the plan doc.
|
|
205
|
+
resolveSendModeImpl = () => 'enqueue',
|
|
206
|
+
// Interactive surfaces. Tests inject a fake bridge; nothing here reaches the
|
|
207
|
+
// relay without one.
|
|
208
|
+
createQuestionBridgeImpl = createCopilotQuestionBridge,
|
|
209
|
+
questionPollMs = undefined,
|
|
210
|
+
questionTimeoutMs = undefined,
|
|
211
|
+
// Preview-lane guidance. Advisory — a failure costs the block, not the turn.
|
|
212
|
+
relayToolInstructions = undefined,
|
|
213
|
+
getPreviewInstructionsImpl = undefined,
|
|
214
|
+
infiniteSessionConfig = DEFAULT_INFINITE_SESSION_CONFIG,
|
|
215
|
+
idleShutdownMs = DEFAULT_IDLE_SHUTDOWN_MS,
|
|
216
|
+
lifecyclePollMs = DEFAULT_LIFECYCLE_POLL_MS,
|
|
217
|
+
// 0 disables (matching the background-task timeout's 0 = no-limit
|
|
218
|
+
// convention). When set, a turn that goes this long without a single event
|
|
219
|
+
// fails terminally instead of holding the queue row until the relay's own
|
|
220
|
+
// delivery watchdog gives up.
|
|
221
|
+
turnStallTimeoutMs = DEFAULT_TURN_STALL_TIMEOUT_MS,
|
|
222
|
+
// How long live detached shells alone may keep the runtime up (0 = no
|
|
223
|
+
// limit). Read through a getter, like the Claude worker's
|
|
224
|
+
// `getBackgroundTaskTimeoutMs`, so a future settings push can move it without
|
|
225
|
+
// a worker restart.
|
|
226
|
+
getBackgroundTaskTimeoutMs = () => DEFAULT_BACKGROUND_TASK_TIMEOUT_MS,
|
|
227
|
+
continuationGraceMs = DEFAULT_CONTINUATION_GRACE_MS,
|
|
228
|
+
continuationRetryDelayMs = DEFAULT_CONTINUATION_RETRY_DELAY_MS,
|
|
229
|
+
// How long a settled continuation waits for its row before giving up on it.
|
|
230
|
+
// Must outlast the registration's own retries; a test shortens it.
|
|
231
|
+
continuationRegistrationTimeoutMs = CONTINUATION_REGISTRATION_TIMEOUT_MS,
|
|
232
|
+
dbg = () => {},
|
|
233
|
+
} = {}) {
|
|
234
|
+
let client = null;
|
|
235
|
+
let session = null;
|
|
236
|
+
let sdkPaths = null;
|
|
237
|
+
let appliedModel = '';
|
|
238
|
+
let activeTurn = null;
|
|
239
|
+
let lifecycleTimer = null;
|
|
240
|
+
let lastActivityAt = Date.now();
|
|
241
|
+
let lastTurnUsage = null;
|
|
242
|
+
// The snapshot object already handed to the ingest. Identity, not a flag, so
|
|
243
|
+
// a turn that captured nothing new cannot re-post the previous turn's numbers
|
|
244
|
+
// (every settle path runs `postTurnUsage`, including the ones that publish no
|
|
245
|
+
// fresh usage at all).
|
|
246
|
+
let postedTurnUsage = null;
|
|
247
|
+
// The in-flight ingest POST, exposed only as a test seam. Never awaited by
|
|
248
|
+
// the turn path — that is the entire point of it.
|
|
249
|
+
let usagePostChain = Promise.resolve();
|
|
250
|
+
let starting = null;
|
|
251
|
+
let disposed = false;
|
|
252
|
+
let detachRuntimeExit = () => {};
|
|
253
|
+
// Prompts this worker sent that the runtime accepted but had not started work
|
|
254
|
+
// on when the interaction ended. They stay in the runtime's pending queue and
|
|
255
|
+
// are picked up FIRST in the next interaction, ahead of that turn's own
|
|
256
|
+
// prompt — so the next turn's segments are shifted by this many, and without
|
|
257
|
+
// it the primary row would be answered with a leftover prompt's reply.
|
|
258
|
+
let carriedPrompts = 0;
|
|
259
|
+
// Blocking handlers currently waiting on a human (`ask_user`, an ask-mode
|
|
260
|
+
// tool approval). The runtime emits NO events while blocked in one, and the
|
|
261
|
+
// question timeout is 8 hours against a 120s stall ceiling — so without this
|
|
262
|
+
// every unanswered card would fail its row after two minutes and then hand
|
|
263
|
+
// the human's eventual answer to a runtime whose row is already settled.
|
|
264
|
+
// Same guard the Cursor worker's `hasPendingClientWork` provides.
|
|
265
|
+
let pendingHumanRequests = 0;
|
|
266
|
+
// The `SessionConfig.provider` block this session was BUILT with, or null for
|
|
267
|
+
// a hosted (`github`) session. Set by `buildSessionConfig` rather than by a
|
|
268
|
+
// second throwaway resolve here, so "is this BYOK?" and "what did the runtime
|
|
269
|
+
// actually get?" can never disagree — which matters because the block's token
|
|
270
|
+
// ceilings are model-specific and the session is rebuilt when the model
|
|
271
|
+
// changes.
|
|
272
|
+
let byokProvider = null;
|
|
273
|
+
// Drops the history a `session.resume` replays through the live callback, so
|
|
274
|
+
// a two-day-old `assistant.message` can never mint a continuation row.
|
|
275
|
+
const replayGate = createReplayGate();
|
|
276
|
+
// The detached shells this session has running. The lifecycle's pin, and the
|
|
277
|
+
// reason a settled one is worth waiting for.
|
|
278
|
+
const backgroundShells = createBackgroundShellTracker();
|
|
279
|
+
// When a shell settled and the continuation it should trigger has not opened
|
|
280
|
+
// yet. Holds the runtime for `continuationGraceMs`; cleared by the
|
|
281
|
+
// continuation opening, or by the grace expiring on a runtime that decided it
|
|
282
|
+
// had nothing to say.
|
|
283
|
+
let continuationDueSince = 0;
|
|
284
|
+
// Transcript lines produced between turns (a settled shell's notification).
|
|
285
|
+
// They belong to the turn they trigger, so they are carried into it rather
|
|
286
|
+
// than dropped — the same trade the Claude worker's `pendingActivities`
|
|
287
|
+
// makes.
|
|
288
|
+
let pendingActivities = [];
|
|
289
|
+
// The relay mode of the last delivered turn. A self-initiated turn has no
|
|
290
|
+
// delivery to read a mode off, and it is a continuation OF that turn's work,
|
|
291
|
+
// so it inherits it — which is what keeps the permission handler and the
|
|
292
|
+
// plan-board gating behaving the same either side of a background wait.
|
|
293
|
+
let lastRelayMode = 'agent';
|
|
294
|
+
|
|
295
|
+
async function whileAwaitingHuman(run) {
|
|
296
|
+
pendingHumanRequests += 1;
|
|
297
|
+
try {
|
|
298
|
+
return await run();
|
|
299
|
+
} finally {
|
|
300
|
+
pendingHumanRequests -= 1;
|
|
301
|
+
// The clock restarts from the answer, not from before the wait.
|
|
302
|
+
touch();
|
|
303
|
+
activeTurn?.armStall?.();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The relay question bridge serves `ask_user` and (in ask mode) tool
|
|
308
|
+
// approvals. `getActiveMessage` must resolve to the row that is CURRENTLY
|
|
309
|
+
// `processing`, because `/api/relay-question` 409s otherwise.
|
|
310
|
+
const questionBridge = createQuestionBridgeImpl({
|
|
311
|
+
api,
|
|
312
|
+
sdkSessionId,
|
|
313
|
+
// A continuation's message has no `id` until its synthetic row is
|
|
314
|
+
// registered; posting a card against a null id would 409. Reporting "no
|
|
315
|
+
// active message" instead lets the bridge take its own degraded path.
|
|
316
|
+
getActiveMessage: () => (activeTurn?.message?.id ? activeTurn.message : null),
|
|
317
|
+
...(questionPollMs === undefined ? {} : { questionPollMs }),
|
|
318
|
+
...(questionTimeoutMs === undefined ? {} : { questionTimeoutMs }),
|
|
319
|
+
dbg,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
const buildRelayContextPrefix = createCopilotPromptContextBuilder({
|
|
323
|
+
toolInstructions: relayToolInstructions === undefined
|
|
324
|
+
? loadDefaultRelayToolInstructions({ env })
|
|
325
|
+
: relayToolInstructions,
|
|
326
|
+
getPreviewInstructions: getPreviewInstructionsImpl === undefined
|
|
327
|
+
? createPreviewInstructionsProvider({ api })
|
|
328
|
+
: getPreviewInstructionsImpl,
|
|
329
|
+
});
|
|
330
|
+
// Every event is handled on one chain so the relay POSTs for a turn land in
|
|
331
|
+
// the order the runtime produced them; the SDK's callback is synchronous and
|
|
332
|
+
// would otherwise interleave awaits.
|
|
333
|
+
let dispatchChain = Promise.resolve();
|
|
334
|
+
|
|
335
|
+
function touch() {
|
|
336
|
+
lastActivityAt = Date.now();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------- publish --
|
|
340
|
+
|
|
341
|
+
async function postActivity(message, text, subagentRunId = null) {
|
|
342
|
+
if (!text) return;
|
|
343
|
+
await api('POST', '/api/activity', {
|
|
344
|
+
messageId: message.id,
|
|
345
|
+
conversationId: message.conversationId,
|
|
346
|
+
mode: message.relayMode || 'agent',
|
|
347
|
+
text,
|
|
348
|
+
...(subagentRunId ? { subagentRunId } : {}),
|
|
349
|
+
}).catch(() => {});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function dispatchAction(message, action, state) {
|
|
353
|
+
const { channel, payload } = action;
|
|
354
|
+
if (channel === 'init') {
|
|
355
|
+
// Inert: the response's model is read straight off the normalizer when
|
|
356
|
+
// the turn settles, so there is nothing to mirror into turn state here.
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (channel === 'stream') {
|
|
360
|
+
// Only main-thread text can stand in for the answer on the abort/error
|
|
361
|
+
// fallback paths; subagent text would publish as the reply.
|
|
362
|
+
if (!payload.subagentRunId) state.lastStreamedText = payload.text;
|
|
363
|
+
await api('POST', '/api/stream', {
|
|
364
|
+
messageId: message.id,
|
|
365
|
+
conversationId: message.conversationId,
|
|
366
|
+
mode: message.relayMode || 'agent',
|
|
367
|
+
text: payload.text,
|
|
368
|
+
done: payload.done === true,
|
|
369
|
+
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
370
|
+
}).catch(() => {});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (channel === 'thought') {
|
|
374
|
+
await api('POST', '/api/thought', {
|
|
375
|
+
messageId: message.id,
|
|
376
|
+
conversationId: message.conversationId,
|
|
377
|
+
mode: message.relayMode || 'agent',
|
|
378
|
+
reasoningId: payload.reasoningId,
|
|
379
|
+
text: payload.text,
|
|
380
|
+
done: payload.done === true,
|
|
381
|
+
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
382
|
+
}).catch(() => {});
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (channel === 'activity') {
|
|
386
|
+
await postActivity(message, payload.text, payload.subagentRunId);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (channel === 'subagent') {
|
|
390
|
+
// Same body as every sibling worker's, so the lane bubbles, the
|
|
391
|
+
// `subagent_status` broadcast and the UI's grouping behave identically
|
|
392
|
+
// whichever provider produced the run.
|
|
393
|
+
await api('POST', '/api/subagent-run', {
|
|
394
|
+
messageId: message.id,
|
|
395
|
+
conversationId: message.conversationId,
|
|
396
|
+
subagentRunId: payload.subagentRunId,
|
|
397
|
+
...(payload.parentSubagentId ? { parentSubagentId: payload.parentSubagentId } : {}),
|
|
398
|
+
...(payload.displayName ? { displayName: payload.displayName } : {}),
|
|
399
|
+
status: payload.status,
|
|
400
|
+
}).catch(() => {});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Force-close any subagent still marked running.
|
|
406
|
+
*
|
|
407
|
+
* The normalizer closes strays when it builds a terminal `result`, but the
|
|
408
|
+
* paths that kill a turn WITHOUT one — a user abort, the runtime exiting, a
|
|
409
|
+
* thrown exception — never get there. The relay only reconciles open runs
|
|
410
|
+
* when the queue row is FAILED, so on the abort path (where the row is
|
|
411
|
+
* settled server-side) an un-closed run would render as a bubble spinning
|
|
412
|
+
* forever.
|
|
413
|
+
*/
|
|
414
|
+
async function closeStraySubagentRuns(turn) {
|
|
415
|
+
const runs = turn?.normalizer?.activeSubagentRuns?.() || [];
|
|
416
|
+
for (const run of runs) {
|
|
417
|
+
await emitAction(turn, {
|
|
418
|
+
channel: 'subagent',
|
|
419
|
+
payload: {
|
|
420
|
+
subagentRunId: run.subagentRunId,
|
|
421
|
+
parentSubagentId: null,
|
|
422
|
+
displayName: run.displayName,
|
|
423
|
+
status: 'failed',
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Publish an action, or hold it if the turn has no queue row yet.
|
|
431
|
+
*
|
|
432
|
+
* The single gate every producer goes through. A continuation's row is
|
|
433
|
+
* created asynchronously, and a POST carrying `messageId: null` is not merely
|
|
434
|
+
* useless — the relay's activity/stream routes key on it, so it would be
|
|
435
|
+
* attributed to nothing at all.
|
|
436
|
+
*/
|
|
437
|
+
async function emitAction(turn, action) {
|
|
438
|
+
if (!turn.registered) {
|
|
439
|
+
turn.bufferedActions.push(action);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
await dispatchAction(turn.message, action, turn.state);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function publishFinalStream(message, text) {
|
|
446
|
+
await api('POST', '/api/stream', {
|
|
447
|
+
messageId: message.id,
|
|
448
|
+
conversationId: message.conversationId,
|
|
449
|
+
mode: message.relayMode || 'agent',
|
|
450
|
+
text: String(text || ''),
|
|
451
|
+
done: true,
|
|
452
|
+
}).catch(() => {});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function publishResponse(message, { text, model, terminalError = null, modelOrigin }) {
|
|
456
|
+
await api('POST', '/api/response', {
|
|
457
|
+
messageId: message.id,
|
|
458
|
+
conversationId: message.conversationId,
|
|
459
|
+
text: String(text || ''),
|
|
460
|
+
model: model || null,
|
|
461
|
+
modelOrigin: modelOrigin
|
|
462
|
+
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
463
|
+
...(terminalError ? { terminalError } : {}),
|
|
464
|
+
}).catch(async () => {
|
|
465
|
+
await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function terminalErrorRecord(message, classified) {
|
|
470
|
+
return {
|
|
471
|
+
kind: 'copilot-turn-failed',
|
|
472
|
+
code: classified.code,
|
|
473
|
+
stableCode: classified.stableCode,
|
|
474
|
+
message: classified.text,
|
|
475
|
+
failedAt: new Date().toISOString(),
|
|
476
|
+
queueMessageId: String(message.id || '') || null,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Per-turn usage capture, recorded on the runner.
|
|
482
|
+
*
|
|
483
|
+
* Synchronous and side-effect-only: it decides WHAT to report, never when.
|
|
484
|
+
* The POST is `postTurnUsage`'s job and runs after the row is published.
|
|
485
|
+
*/
|
|
486
|
+
function captureTurnUsage(message, result) {
|
|
487
|
+
const usage = result?.usage || null;
|
|
488
|
+
const contextUsage = result?.contextUsage || null;
|
|
489
|
+
if (!usage && !contextUsage) return;
|
|
490
|
+
lastTurnUsage = {
|
|
491
|
+
conversationId: message.conversationId,
|
|
492
|
+
sdkSessionId,
|
|
493
|
+
messageId: message.id,
|
|
494
|
+
model: result?.model || appliedModel || defaultModel || '',
|
|
495
|
+
usage,
|
|
496
|
+
contextUsage,
|
|
497
|
+
capturedAt: new Date().toISOString(),
|
|
498
|
+
};
|
|
499
|
+
dbg('turn usage', JSON.stringify({
|
|
500
|
+
model: lastTurnUsage.model,
|
|
501
|
+
inputTokens: usage?.inputTokens ?? null,
|
|
502
|
+
outputTokens: usage?.outputTokens ?? null,
|
|
503
|
+
// The premium MULTIPLIER, not money.
|
|
504
|
+
cost: usage?.cost ?? null,
|
|
505
|
+
// Real spend, and the field a usage card should show.
|
|
506
|
+
totalNanoAiu: usage?.totalNanoAiu ?? null,
|
|
507
|
+
modelCalls: usage?.modelCalls ?? null,
|
|
508
|
+
subagentModelCalls: usage?.subagentModelCalls ?? null,
|
|
509
|
+
timeToFirstTokenMs: usage?.timeToFirstTokenMs ?? null,
|
|
510
|
+
contextTokens: contextUsage?.currentTokens ?? null,
|
|
511
|
+
// Overage lives at `quotaSnapshots.cfi_overage`; `account.getQuota()`
|
|
512
|
+
// reads a stale cache and will not show it.
|
|
513
|
+
hasQuotaSnapshots: !!usage?.quotaSnapshots,
|
|
514
|
+
cfiOverage: usage?.quotaSnapshots?.cfi_overage ?? null,
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Report the captured turn usage to the relay's `/api/copilot-plan-usage`
|
|
520
|
+
* ingest. Fire-and-forget, deliberately, and always AFTER the row has been
|
|
521
|
+
* published.
|
|
522
|
+
*
|
|
523
|
+
* The plan card's meters come from the account-level quota API the relay
|
|
524
|
+
* fetches itself, and those already cover SDK sessions. What only the worker
|
|
525
|
+
* can see is the per-turn detail: `totalNanoAiu` (real spend — the event's
|
|
526
|
+
* `cost` is the premium multiplier, not money) and
|
|
527
|
+
* `quotaSnapshots.cfi_overage` (overage, invisible to `account.getQuota()`'s
|
|
528
|
+
* cached read). None of that is worth one millisecond of a finished reply.
|
|
529
|
+
*
|
|
530
|
+
* Awaiting it was actively dangerous: by this point the stall watchdog is
|
|
531
|
+
* disarmed and the relay client has no request timeout, so an unresponsive
|
|
532
|
+
* relay could hold a COMPLETED turn — its text already generated, its queue
|
|
533
|
+
* row still open — for as long as the socket stayed up. The result is unused
|
|
534
|
+
* and the failure is already swallowed, so there was nothing to wait for.
|
|
535
|
+
*
|
|
536
|
+
* BYOK sessions do not post at all: they spend the user's own OpenAI key
|
|
537
|
+
* rather than Copilot quota, and their usage events report `cost: 0`, so
|
|
538
|
+
* their numbers would only mislead on a card about the Copilot plan.
|
|
539
|
+
*/
|
|
540
|
+
function postTurnUsage() {
|
|
541
|
+
if (byokProvider || !lastTurnUsage || lastTurnUsage === postedTurnUsage) return;
|
|
542
|
+
postedTurnUsage = lastTurnUsage;
|
|
543
|
+
// Held so a test (and only a test) can await the settle; nothing in the
|
|
544
|
+
// turn path ever reads it.
|
|
545
|
+
usagePostChain = api('POST', '/api/copilot-plan-usage', lastTurnUsage)
|
|
546
|
+
.catch((error) => { dbg('usage ingest failed', error?.message || String(error)); });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------- session --
|
|
550
|
+
|
|
551
|
+
function routeEvent(event) {
|
|
552
|
+
// A replayed event is history the relay already has. It must not touch the
|
|
553
|
+
// idle clock, the shell set, an active turn's normalizer, or — the reason
|
|
554
|
+
// this gate exists at all — open a continuation row for work that finished
|
|
555
|
+
// days ago.
|
|
556
|
+
if (replayGate.isReplay(event)) {
|
|
557
|
+
dbg('dropping a replayed event', String(event?.type || ''), String(event?.timestamp || ''));
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
touch();
|
|
561
|
+
observeBackgroundShells(event);
|
|
562
|
+
let turn = activeTurn;
|
|
563
|
+
if (!turn) {
|
|
564
|
+
// The runtime started work with no row open. Anything that is not the
|
|
565
|
+
// START of work (terminators, connection bookkeeping) stays a no-op:
|
|
566
|
+
// opening a row for one would leave a synthetic turn in the transcript
|
|
567
|
+
// that only the stall watchdog could close.
|
|
568
|
+
if (!isContinuationOpeningEvent(event)) return;
|
|
569
|
+
turn = openContinuationTurn();
|
|
570
|
+
}
|
|
571
|
+
// `activeTurn` is deliberately still set (and still settled) while a turn
|
|
572
|
+
// publishes its response, so a continuation cannot open inside that window
|
|
573
|
+
// and clobber the row being written. Events landing there are dropped by
|
|
574
|
+
// `handleTurnEvent`'s settled guard — and cost nothing, because the opener
|
|
575
|
+
// allowlist spans the whole of a turn: the next `assistant.message` or
|
|
576
|
+
// `tool.execution_start` opens the continuation once the row is closed,
|
|
577
|
+
// with the normalizer still capturing the reply text.
|
|
578
|
+
dispatchChain = dispatchChain
|
|
579
|
+
.then(() => handleTurnEvent(turn, event))
|
|
580
|
+
.catch((error) => { dbg('event dispatch failed', error?.message || String(error)); });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Keep the detached-shell set current and note when one settles.
|
|
585
|
+
*
|
|
586
|
+
* Runs for every live event, in or out of a turn: shells are OPENED inside a
|
|
587
|
+
* delivered turn (that is where the model calls `bash`) and SETTLE outside
|
|
588
|
+
* one, which is the whole asymmetry this fix exists for.
|
|
589
|
+
*/
|
|
590
|
+
function observeBackgroundShells(event) {
|
|
591
|
+
let changed;
|
|
592
|
+
try {
|
|
593
|
+
changed = backgroundShells.observe(event);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
dbg('background shell tracking failed', String(event?.type || ''), error?.message || String(error));
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
for (const shell of changed.opened) {
|
|
599
|
+
dbg('detached shell started', shell.shellId, shell.description || '(no description)');
|
|
600
|
+
}
|
|
601
|
+
for (const shell of changed.settled) {
|
|
602
|
+
dbg('detached shell settled', shell.shellId);
|
|
603
|
+
const note = describeSettledShell(shell);
|
|
604
|
+
// Only carried when there is no turn to publish into: inside a turn the
|
|
605
|
+
// normalizer already narrates the `read_bash` that reads the output.
|
|
606
|
+
if (note && !activeTurn && pendingActivities.length < MAX_PENDING_ACTIVITIES) {
|
|
607
|
+
pendingActivities.push(note);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Only the runtime's own `system.notification` heralds a continuation. A
|
|
611
|
+
// `read_bash` that reports an exit code closes the same shell, but it
|
|
612
|
+
// happens inside a turn that already knows — pinning on it would hold the
|
|
613
|
+
// runtime for the grace window after every ordinary background command.
|
|
614
|
+
//
|
|
615
|
+
// Set even when a turn is active: the gap that matters is the one AFTER
|
|
616
|
+
// that turn closes.
|
|
617
|
+
if (changed.heralded) continuationDueSince = Date.now();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function handleTurnEvent(turn, event) {
|
|
621
|
+
if (turn.settled) return;
|
|
622
|
+
turn.armStall?.();
|
|
623
|
+
let actions = [];
|
|
624
|
+
try {
|
|
625
|
+
actions = turn.normalizer.normalize(event);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
dbg('normalize failed', String(event?.type || ''), error?.message || String(error));
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
for (const action of actions) {
|
|
631
|
+
if (action.channel === 'result') {
|
|
632
|
+
turn.result = action.payload;
|
|
633
|
+
turn.settle();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// A continuation's actions buffer until its synthetic row exists; the
|
|
637
|
+
// registration drains them in arrival order and only then flips
|
|
638
|
+
// `registered`, so a later action can never overtake an earlier one.
|
|
639
|
+
// Delivered turns are registered from birth and take the direct path.
|
|
640
|
+
await emitAction(turn, action);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Publish everything a not-yet-registered turn buffered, in order. */
|
|
645
|
+
async function flushBufferedActions(turn) {
|
|
646
|
+
while (turn.bufferedActions.length) {
|
|
647
|
+
const batch = turn.bufferedActions.splice(0);
|
|
648
|
+
for (const action of batch) {
|
|
649
|
+
await dispatchAction(turn.message, action, turn.state).catch(() => {});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function buildSessionConfig(model, relayMode) {
|
|
655
|
+
// Built once per session, not per request. Reads the mode off the LIVE turn
|
|
656
|
+
// rather than closing over the one the session was built with, because one
|
|
657
|
+
// session serves every turn and the user can switch modes between them.
|
|
658
|
+
const decidePermission = createCopilotPermissionHandler({
|
|
659
|
+
// Only the ask-mode branch actually blocks on a human; wrapping it keeps
|
|
660
|
+
// the stall watchdog off a turn where someone is deciding.
|
|
661
|
+
bridge: {
|
|
662
|
+
askToolApproval: (permissionRequest, options) => whileAwaitingHuman(
|
|
663
|
+
() => questionBridge.askToolApproval(permissionRequest, options),
|
|
664
|
+
),
|
|
665
|
+
},
|
|
666
|
+
getRelayMode: () => activeTurn?.message?.relayMode || relayMode,
|
|
667
|
+
// An aborted turn must not leave the human staring at a card whose answer
|
|
668
|
+
// nothing will read; the bridge times the card out instead.
|
|
669
|
+
getSignal: () => activeTurn?.abortController?.signal || null,
|
|
670
|
+
dbg,
|
|
671
|
+
});
|
|
672
|
+
// BYOK sessions must carry their provider IN the session config: the
|
|
673
|
+
// runtime's `COPILOT_PROVIDER_*` startup layer does not run for
|
|
674
|
+
// SDK-created sessions, so without this an OpenAI-provider conversation
|
|
675
|
+
// would silently run on hosted Copilot models instead. Null for every
|
|
676
|
+
// hosted (`github`) session, which is the common case.
|
|
677
|
+
//
|
|
678
|
+
// The resolved block is remembered: its token ceilings describe THIS model,
|
|
679
|
+
// and `session.setModel()` cannot update them (see `applyModel`).
|
|
680
|
+
const provider = resolveProviderConfigImpl({ env, model: model || defaultModel, dbg });
|
|
681
|
+
byokProvider = provider;
|
|
682
|
+
return {
|
|
683
|
+
// The relay session id IS the SDK session id, so the runtime's own state
|
|
684
|
+
// under ~/.copilot/session-state/<id> is addressable by conversation and
|
|
685
|
+
// survives worker restarts without a side table.
|
|
686
|
+
sessionId: sdkSessionId,
|
|
687
|
+
...(model ? { model } : {}),
|
|
688
|
+
...(provider ? { provider } : {}),
|
|
689
|
+
// Not a default: without this the SDK emits no deltas at all and the
|
|
690
|
+
// transcript only updates when the whole message lands.
|
|
691
|
+
streaming: true,
|
|
692
|
+
workingDirectory: cwd,
|
|
693
|
+
clientName,
|
|
694
|
+
// The runtime's own defaults, pinned. See DEFAULT_INFINITE_SESSION_CONFIG.
|
|
695
|
+
...(infiniteSessionConfig ? { infiniteSessions: { ...infiniteSessionConfig } } : {}),
|
|
696
|
+
onEvent: routeEvent,
|
|
697
|
+
// Permission policy follows the conversation's relay mode: agent and
|
|
698
|
+
// autopilot auto-approve, plan denies non-read tools with feedback, and
|
|
699
|
+
// ask asks the human through a relay question card. The handler reads the
|
|
700
|
+
// mode from the LIVE turn rather than closing over the mode the session
|
|
701
|
+
// was built with, because one session serves every turn of a conversation
|
|
702
|
+
// and the user can switch modes between them.
|
|
703
|
+
onPermissionRequest: async (request) => {
|
|
704
|
+
const decision = await decidePermission(request);
|
|
705
|
+
// Remember that this turn actually changed something: it is what tells
|
|
706
|
+
// a described plan apart from work already done, which is the
|
|
707
|
+
// difference between a useful handoff board and a nonsensical one.
|
|
708
|
+
if (decision?.kind === 'approve-once' && !isReadOnlyPermissionRequest(request) && activeTurn) {
|
|
709
|
+
activeTurn.acted = true;
|
|
710
|
+
}
|
|
711
|
+
return decision;
|
|
712
|
+
},
|
|
713
|
+
// `ask_user` → a relay question card. The runtime BLOCKS the turn on this
|
|
714
|
+
// handler, so it must always settle: a throw would fail the tool call
|
|
715
|
+
// silently, and a hang would hold the queue row until the delivery
|
|
716
|
+
// watchdog gives up. `wasFreeform` is required by `UserInputResponse` and
|
|
717
|
+
// the deserializer is strict, so it is always a real boolean.
|
|
718
|
+
onUserInputRequest: async (request) => {
|
|
719
|
+
try {
|
|
720
|
+
const { answer, wasFreeform } = await whileAwaitingHuman(() => questionBridge.askUserInput(request, {
|
|
721
|
+
signal: activeTurn?.abortController?.signal || null,
|
|
722
|
+
}));
|
|
723
|
+
return { answer, wasFreeform };
|
|
724
|
+
} catch (error) {
|
|
725
|
+
dbg('user input question failed', error?.message || String(error));
|
|
726
|
+
return { answer: USER_INPUT_UNSUPPORTED_ANSWER, wasFreeform: true };
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
// The agent finished planning. Post the board and REFUSE the exit:
|
|
730
|
+
// approving it tells the runtime the plan was accepted and the same turn
|
|
731
|
+
// rolls straight into implementing while the board sits unanswered.
|
|
732
|
+
onExitPlanModeRequest: async (request) => {
|
|
733
|
+
const posted = await publishPlanBoard(activeTurn, planTextFromExitRequest(request), 'exit_plan_mode');
|
|
734
|
+
return {
|
|
735
|
+
approved: false,
|
|
736
|
+
feedback: posted ? EXIT_PLAN_BOARD_POSTED_FEEDBACK : EXIT_PLAN_NO_BOARD_FEEDBACK,
|
|
737
|
+
};
|
|
738
|
+
},
|
|
739
|
+
// Structured elicitation has no relay card type of its own; declining is
|
|
740
|
+
// in-band and lets the model continue, which a hang would not.
|
|
741
|
+
onElicitationRequest: () => ({ action: 'decline' }),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Post the `plan_ready` board for a turn. Returns whether a board went out,
|
|
747
|
+
* which the exit-plan handler turns into the feedback the agent sees.
|
|
748
|
+
*
|
|
749
|
+
* Marks the turn so the completion path's text-shape fallback does not post a
|
|
750
|
+
* second board — the relay would dedupe it (`UNIQUE(message_id, board_type)`)
|
|
751
|
+
* but only after a pointless round trip.
|
|
752
|
+
*/
|
|
753
|
+
async function publishPlanBoard(turn, planText, source) {
|
|
754
|
+
if (!turn?.message?.id) return false;
|
|
755
|
+
const payload = buildCopilotPlanReadyBoardPayload({ message: turn.message, planText, source });
|
|
756
|
+
if (!payload) return false;
|
|
757
|
+
// The failure is swallowed (a relay that refused the board must not fail a
|
|
758
|
+
// turn that otherwise succeeded) but it is REPORTED: telling the agent the
|
|
759
|
+
// plan is "shown to the user for review" when it is not would end the turn
|
|
760
|
+
// with the plan visible nowhere, and would also latch off the text-shape
|
|
761
|
+
// fallback that could still have posted it.
|
|
762
|
+
try {
|
|
763
|
+
await api('POST', '/api/relay-board', payload);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
dbg('plan board publish failed', error?.message || String(error));
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
turn.planBoardPosted = true;
|
|
769
|
+
return true;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The runtime process died under us. Nothing the SDK is waiting on will ever
|
|
774
|
+
* resolve, so the active turn is failed terminally-but-retryably rather than
|
|
775
|
+
* left to hold its queue row open behind a heartbeat that keeps renewing the
|
|
776
|
+
* processing lease.
|
|
777
|
+
*/
|
|
778
|
+
function handleRuntimeExit(detail) {
|
|
779
|
+
dbg('copilot runtime exited', detail);
|
|
780
|
+
const turn = activeTurn;
|
|
781
|
+
if (turn && !turn.settled) {
|
|
782
|
+
// handlePendingPayload's catch tears the runtime down and publishes the
|
|
783
|
+
// failure record.
|
|
784
|
+
turn.fail(new Error(`the Copilot runtime exited before the turn completed (${detail})`));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
// No turn to fail: drop the dead handles so the next delivery rebuilds
|
|
788
|
+
// instead of sending into a corpse.
|
|
789
|
+
void stopRuntime('runtime-exit').catch(() => {});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
async function ensureClient() {
|
|
793
|
+
if (client) return client;
|
|
794
|
+
if (starting) return starting;
|
|
795
|
+
starting = (async () => {
|
|
796
|
+
sdkPaths = resolvePathsImpl({ env });
|
|
797
|
+
const started = await startClientImpl({ paths: sdkPaths, cwd, clientName, logLevel, dbg });
|
|
798
|
+
client = started.client;
|
|
799
|
+
detachRuntimeExit = observeRuntimeExit(client, handleRuntimeExit);
|
|
800
|
+
// Diagnostics only, and deliberately not awaited — see startCopilotClient.
|
|
801
|
+
Promise.resolve(started.versionReady)
|
|
802
|
+
.then((info) => {
|
|
803
|
+
if (info?.versionSkewWarning) dbg(info.versionSkewWarning);
|
|
804
|
+
else dbg(`copilot runtime ready (version ${info?.runtimeVersion || sdkPaths.version || 'unknown'})`);
|
|
805
|
+
})
|
|
806
|
+
.catch(() => {});
|
|
807
|
+
startLifecycleTimer();
|
|
808
|
+
return client;
|
|
809
|
+
})();
|
|
810
|
+
try {
|
|
811
|
+
return await starting;
|
|
812
|
+
} finally {
|
|
813
|
+
starting = null;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Best-effort model switch: a rejected switch must not fail the turn, it
|
|
819
|
+
* just runs on the session's current model (and says so in the log).
|
|
820
|
+
*
|
|
821
|
+
* This is the HOSTED path only. A BYOK session cannot switch this way — see
|
|
822
|
+
* `switchModel`.
|
|
823
|
+
*/
|
|
824
|
+
async function applyModel(model) {
|
|
825
|
+
if (!model || typeof session?.setModel !== 'function') return;
|
|
826
|
+
try {
|
|
827
|
+
await session.setModel(model);
|
|
828
|
+
appliedModel = model;
|
|
829
|
+
} catch (error) {
|
|
830
|
+
dbg('copilot setModel failed', model, error?.message || String(error));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Switch a live session onto a different model.
|
|
836
|
+
*
|
|
837
|
+
* Hosted sessions just call `setModel()`. BYOK sessions cannot: their token
|
|
838
|
+
* ceilings live in `SessionConfig.provider`, which is fixed at session
|
|
839
|
+
* creation. Runtime 1.0.82 exposes **no** way to update it — `setModel()`
|
|
840
|
+
* takes reasoning/context options but no provider, there is no
|
|
841
|
+
* `setProvider`, and the one runtime registry-add RPC belongs to the
|
|
842
|
+
* experimental named-`providers`/`models` surface, which the runtime
|
|
843
|
+
* explicitly REJECTS when combined with the singular whole-session
|
|
844
|
+
* `provider` this worker uses.
|
|
845
|
+
*
|
|
846
|
+
* So a bare `setModel()` on a BYOK session leaves the previous model's
|
|
847
|
+
* ceilings in place, and both directions of that are harmful: ceilings too
|
|
848
|
+
* high turn compaction into hard API rejections, ceilings too low compact a
|
|
849
|
+
* conversation that had plenty of room left.
|
|
850
|
+
*
|
|
851
|
+
* The session is therefore disposed and rebuilt with freshly resolved
|
|
852
|
+
* ceilings. Nothing is lost: the relay session id IS the SDK session id, the
|
|
853
|
+
* runtime's state persists under it, and the rebuild takes the ordinary
|
|
854
|
+
* resume path — the same one every worker restart already uses.
|
|
855
|
+
*/
|
|
856
|
+
async function switchModel(model, relayMode) {
|
|
857
|
+
if (!byokProvider) {
|
|
858
|
+
await applyModel(model);
|
|
859
|
+
return session;
|
|
860
|
+
}
|
|
861
|
+
dbg('rebuilding the copilot session for a BYOK model switch', model);
|
|
862
|
+
const closing = session;
|
|
863
|
+
session = null;
|
|
864
|
+
appliedModel = '';
|
|
865
|
+
// Disconnect first so the runtime is not holding two handles on one
|
|
866
|
+
// session id while the resume runs.
|
|
867
|
+
try { await closing?.disconnect?.(); } catch (error) {
|
|
868
|
+
dbg('session disconnect before model switch failed', error?.message || String(error));
|
|
869
|
+
}
|
|
870
|
+
return ensureSession(model, relayMode);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async function ensureSession(model, relayMode) {
|
|
874
|
+
await ensureClient();
|
|
875
|
+
if (!session) {
|
|
876
|
+
const config = buildSessionConfig(model, relayMode);
|
|
877
|
+
// Resume first, always. On a brand-new conversation this costs one
|
|
878
|
+
// failed RPC; on every other path (worker restart, idle shutdown,
|
|
879
|
+
// relay restart) it is the difference between continuing the
|
|
880
|
+
// conversation and silently starting a fresh one. A negative result is
|
|
881
|
+
// deliberately not cached: once `createSession` succeeds, the state
|
|
882
|
+
// exists and the NEXT reconnect must resume it.
|
|
883
|
+
let resumed = false;
|
|
884
|
+
try {
|
|
885
|
+
session = await client.resumeSession(sdkSessionId, config);
|
|
886
|
+
resumed = true;
|
|
887
|
+
dbg(`resumed copilot session ${sdkSessionId.slice(0, 8)}`);
|
|
888
|
+
} catch (error) {
|
|
889
|
+
// ONLY a definitive "no such session" may fall through to creating a
|
|
890
|
+
// blank one. A dropped connection or an unrecognised failure is
|
|
891
|
+
// transient, and starting fresh over live state would silently throw
|
|
892
|
+
// the conversation's whole history away — so it fails the turn
|
|
893
|
+
// instead, which is retryable and loses nothing.
|
|
894
|
+
if (!isSessionNotFoundError(error)) {
|
|
895
|
+
dbg('copilot session resume failed transiently', error?.message || String(error));
|
|
896
|
+
throw error;
|
|
897
|
+
}
|
|
898
|
+
dbg('copilot session state not found, creating', error?.message || String(error));
|
|
899
|
+
session = await client.createSession(config);
|
|
900
|
+
dbg(`created copilot session ${sdkSessionId.slice(0, 8)}`);
|
|
901
|
+
}
|
|
902
|
+
if (resumed) {
|
|
903
|
+
// A resumed session keeps whatever model it was created with —
|
|
904
|
+
// `config.model` is not guaranteed to be honoured on resume — so the
|
|
905
|
+
// requested model is applied explicitly rather than assumed. Assuming
|
|
906
|
+
// it is what made a mismatch permanent: `appliedModel` would already
|
|
907
|
+
// equal the request, so the per-turn switch below could never fire.
|
|
908
|
+
appliedModel = '';
|
|
909
|
+
await applyModel(model);
|
|
910
|
+
} else {
|
|
911
|
+
appliedModel = model || '';
|
|
912
|
+
}
|
|
913
|
+
return session;
|
|
914
|
+
}
|
|
915
|
+
if (model && model !== appliedModel) return switchModel(model, relayMode);
|
|
916
|
+
return session;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
async function stopRuntime(reason) {
|
|
920
|
+
stopLifecycleTimer();
|
|
921
|
+
try { detachRuntimeExit(); } catch { /* the observer is best-effort */ }
|
|
922
|
+
detachRuntimeExit = () => {};
|
|
923
|
+
const closingSession = session;
|
|
924
|
+
const closingClient = client;
|
|
925
|
+
session = null;
|
|
926
|
+
client = null;
|
|
927
|
+
appliedModel = '';
|
|
928
|
+
// Detached shells are children of the runtime process, so stopping it ends
|
|
929
|
+
// them: the tracked set is state about a process that no longer exists and
|
|
930
|
+
// must not pin the next one. The replay gate is reset for the same reason —
|
|
931
|
+
// the next connection resumes and arms its own window.
|
|
932
|
+
backgroundShells.reset();
|
|
933
|
+
replayGate.reset();
|
|
934
|
+
continuationDueSince = 0;
|
|
935
|
+
pendingActivities = [];
|
|
936
|
+
if (!closingSession && !closingClient) return;
|
|
937
|
+
dbg(`stopping the copilot runtime (${reason})`);
|
|
938
|
+
try { await closingSession?.disconnect?.(); } catch (error) {
|
|
939
|
+
dbg('session disconnect failed', error?.message || String(error));
|
|
940
|
+
}
|
|
941
|
+
try { await closingClient?.stop?.(); } catch (error) {
|
|
942
|
+
dbg('client stop failed', error?.message || String(error));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// -------------------------------------------------------------- lifecycle --
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Whether background work is holding the runtime open.
|
|
950
|
+
*
|
|
951
|
+
* `stopRuntime` ends the CLI runtime process, and a detached shell is a child
|
|
952
|
+
* of it — so stopping while one is live does not merely postpone the
|
|
953
|
+
* continuation, it KILLS the command. The live capture's 1-minute timer
|
|
954
|
+
* survived only because it was shorter than the 10-minute idle window; a
|
|
955
|
+
* 15-minute one would have been silently destroyed.
|
|
956
|
+
*
|
|
957
|
+
* Two holds, both bounded:
|
|
958
|
+
*
|
|
959
|
+
* - a settled shell whose continuation has not opened yet
|
|
960
|
+
* (`continuationGraceMs`), because the runtime is about to re-invoke the
|
|
961
|
+
* model and closing it in that gap loses the reply;
|
|
962
|
+
* - live shells, until `getBackgroundTaskTimeoutMs()` (0 = no limit). On
|
|
963
|
+
* expiry the shells are FORGOTTEN rather than stopped: unlike the Claude
|
|
964
|
+
* SDK, runtime 1.0.82 exposes no way to stop a shell from the host side
|
|
965
|
+
* (`stop_bash` is a tool the *model* calls), so the honest choice is to
|
|
966
|
+
* stop pretending they will report back and let the runtime — and with it
|
|
967
|
+
* the shells — go. Logged, because it means a command was cut short.
|
|
968
|
+
*/
|
|
969
|
+
function backgroundWorkHoldsRuntime() {
|
|
970
|
+
const now = Date.now();
|
|
971
|
+
if (continuationDueSince) {
|
|
972
|
+
if (now - continuationDueSince < continuationGraceMs) return true;
|
|
973
|
+
// The runtime notified and then decided it had nothing to say. Stop
|
|
974
|
+
// pinning; this is the Claude worker's `notificationGraceMs` backstop.
|
|
975
|
+
dbg('continuation grace expired with no continuation turn');
|
|
976
|
+
continuationDueSince = 0;
|
|
977
|
+
}
|
|
978
|
+
if (!backgroundShells.size()) return false;
|
|
979
|
+
const capMs = Number(getBackgroundTaskTimeoutMs()) || 0;
|
|
980
|
+
for (const shell of backgroundShells.expireOlderThan(capMs)) {
|
|
981
|
+
dbg(
|
|
982
|
+
'background shell cap reached; it no longer holds the runtime open',
|
|
983
|
+
shell.shellId,
|
|
984
|
+
shell.description || '(no description)',
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
return backgroundShells.size() > 0;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function evaluateLifecycle() {
|
|
991
|
+
// A pending question card means a human is mid-answer; tearing the runtime
|
|
992
|
+
// down under them would discard the session the answer belongs to.
|
|
993
|
+
if (disposed || activeTurn || pendingHumanRequests > 0 || !client) return;
|
|
994
|
+
if (!(idleShutdownMs > 0)) return;
|
|
995
|
+
// Evaluated before the idle clock so the caps still expire on a runtime
|
|
996
|
+
// that has been quiet far longer than the idle window.
|
|
997
|
+
if (backgroundWorkHoldsRuntime()) return;
|
|
998
|
+
if (Date.now() - lastActivityAt < idleShutdownMs) return;
|
|
999
|
+
void stopRuntime('idle').catch(() => {});
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function startLifecycleTimer() {
|
|
1003
|
+
if (lifecycleTimer || !(idleShutdownMs > 0)) return;
|
|
1004
|
+
lifecycleTimer = setInterval(evaluateLifecycle, lifecyclePollMs);
|
|
1005
|
+
lifecycleTimer.unref?.();
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function stopLifecycleTimer() {
|
|
1009
|
+
if (!lifecycleTimer) return;
|
|
1010
|
+
clearInterval(lifecycleTimer);
|
|
1011
|
+
lifecycleTimer = null;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ------------------------------------------------------------------- turn --
|
|
1015
|
+
|
|
1016
|
+
function resolvePerTurnModel(message) {
|
|
1017
|
+
const requested = String(message?.model || '').trim();
|
|
1018
|
+
if (requested && requested.toLowerCase() !== 'auto') return requested;
|
|
1019
|
+
return String(message?.providerModel || '').trim() || defaultModel;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function createTurn(message, { kind = 'delivered' } = {}) {
|
|
1023
|
+
const continuation = kind === 'continuation';
|
|
1024
|
+
const turn = {
|
|
1025
|
+
// 'delivered' (a queue row arrived) | 'continuation' (the runtime started
|
|
1026
|
+
// work by itself and this worker minted a synthetic row for it).
|
|
1027
|
+
kind,
|
|
1028
|
+
message,
|
|
1029
|
+
normalizer: createNormalizerImpl(),
|
|
1030
|
+
state: { lastStreamedText: '' },
|
|
1031
|
+
result: null,
|
|
1032
|
+
settled: false,
|
|
1033
|
+
aborted: false,
|
|
1034
|
+
stallTimer: null,
|
|
1035
|
+
planBoardPosted: false,
|
|
1036
|
+
// Set when a mutating tool was actually approved and run this turn.
|
|
1037
|
+
acted: false,
|
|
1038
|
+
// A delivered row exists before the turn does, so its actions publish
|
|
1039
|
+
// straight away. A continuation's row is created asynchronously, so its
|
|
1040
|
+
// actions buffer here until it has an id.
|
|
1041
|
+
registered: !continuation,
|
|
1042
|
+
bufferedActions: [],
|
|
1043
|
+
// Set when the synthetic row could not be created; the turn's relay
|
|
1044
|
+
// output is dropped (it still lands in the runtime's own transcript)
|
|
1045
|
+
// rather than failing the worker.
|
|
1046
|
+
discarded: false,
|
|
1047
|
+
controlState: null,
|
|
1048
|
+
// How many prompts have been SENT into this interaction. The runtime
|
|
1049
|
+
// consumes queued prompts in order, and opens one `user.message` segment
|
|
1050
|
+
// per prompt as it picks each up, so send order IS segment order.
|
|
1051
|
+
//
|
|
1052
|
+
// Deliberately not derived from the normalizer's live segment count: at
|
|
1053
|
+
// the moment a steering send happens the runtime may not have opened the
|
|
1054
|
+
// previous prompt's segment yet, and the steered row would then be
|
|
1055
|
+
// attributed the PREVIOUS prompt's answer.
|
|
1056
|
+
//
|
|
1057
|
+
// Starts at 1, not 0: this turn's own prompt is always its first, and
|
|
1058
|
+
// counting it here rather than after `send()` resolves closes the race
|
|
1059
|
+
// where a steering delivery lands between the two. A continuation sent no
|
|
1060
|
+
// prompt at all, so it starts at 0.
|
|
1061
|
+
promptsSent: continuation ? 0 : 1,
|
|
1062
|
+
// The segment this turn's OWN reply lands in. For a delivered turn that
|
|
1063
|
+
// is where its prompt is picked up — non-zero when a previous interaction
|
|
1064
|
+
// left prompts queued inside the runtime, which are consumed first. A
|
|
1065
|
+
// continuation has no `user.message` of its own, so the normalizer's
|
|
1066
|
+
// implicit first segment is its own.
|
|
1067
|
+
baseSegment: continuation ? 0 : carriedPrompts,
|
|
1068
|
+
// The segment the NEXT prompt steered into this interaction will be
|
|
1069
|
+
// answered in. Tracked explicitly rather than derived, because the two
|
|
1070
|
+
// kinds differ: a delivered turn's own prompt occupies `baseSegment`, so
|
|
1071
|
+
// the next is `baseSegment + 1`; a continuation occupies segment 0
|
|
1072
|
+
// without having sent anything, so the first steered prompt opens
|
|
1073
|
+
// segment 1. Getting this wrong cross-publishes the continuation's reply
|
|
1074
|
+
// into the user's row.
|
|
1075
|
+
nextSegmentIndex: continuation ? 1 : carriedPrompts + 1,
|
|
1076
|
+
// The lowest segment index any prompt SENT by this turn can occupy — the
|
|
1077
|
+
// floor for the carried-prompt arithmetic when the interaction ends.
|
|
1078
|
+
firstSentSegment: continuation ? 1 : carriedPrompts,
|
|
1079
|
+
// Cancels any relay question card this turn is blocked on, so an aborted
|
|
1080
|
+
// turn does not leave a human answering into the void.
|
|
1081
|
+
abortController: new AbortController(),
|
|
1082
|
+
// Queue rows delivered mid-turn and steered into this interaction. Each
|
|
1083
|
+
// one is owed a response by THIS turn — the runtime answers them all
|
|
1084
|
+
// under a single `session.idle`, so nothing else will settle them.
|
|
1085
|
+
steeredRows: [],
|
|
1086
|
+
};
|
|
1087
|
+
turn.done = new Promise((resolve, reject) => {
|
|
1088
|
+
turn.resolveDone = resolve;
|
|
1089
|
+
turn.rejectDone = reject;
|
|
1090
|
+
});
|
|
1091
|
+
// The stall watchdog and the runtime-exit observer can reject `turn.done`
|
|
1092
|
+
// before `runTurn` reaches its `await` — an unhandled rejection that the
|
|
1093
|
+
// worker crash guard would escalate into a whole-worker failure. This
|
|
1094
|
+
// handler exists only to mark the promise as handled; the real await path
|
|
1095
|
+
// still sees the rejection.
|
|
1096
|
+
turn.done.catch(() => {});
|
|
1097
|
+
turn.settle = () => {
|
|
1098
|
+
if (turn.settled) return;
|
|
1099
|
+
turn.settled = true;
|
|
1100
|
+
turn.disarmStall();
|
|
1101
|
+
turn.resolveDone();
|
|
1102
|
+
};
|
|
1103
|
+
turn.fail = (error) => {
|
|
1104
|
+
if (turn.settled) return;
|
|
1105
|
+
turn.settled = true;
|
|
1106
|
+
turn.disarmStall();
|
|
1107
|
+
turn.rejectDone(error);
|
|
1108
|
+
};
|
|
1109
|
+
turn.disarmStall = () => {
|
|
1110
|
+
if (!turn.stallTimer) return;
|
|
1111
|
+
clearTimeout(turn.stallTimer);
|
|
1112
|
+
turn.stallTimer = null;
|
|
1113
|
+
};
|
|
1114
|
+
turn.armStall = () => {
|
|
1115
|
+
if (!(turnStallTimeoutMs > 0) || turn.settled) return;
|
|
1116
|
+
turn.disarmStall();
|
|
1117
|
+
turn.stallTimer = setTimeout(() => {
|
|
1118
|
+
// A human staring at a question card is not a stalled runtime. Re-arm
|
|
1119
|
+
// rather than fail: the card has its own (much longer) timeout, and
|
|
1120
|
+
// failing the row here would settle it while the answer is still coming.
|
|
1121
|
+
if (pendingHumanRequests > 0) {
|
|
1122
|
+
turn.armStall();
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
turn.fail(new Error(
|
|
1126
|
+
`copilot worker watchdog: the runtime produced no events for ${Math.round(turnStallTimeoutMs / 1000)}s; `
|
|
1127
|
+
+ 'the row is failed — resend the message to retry',
|
|
1128
|
+
));
|
|
1129
|
+
}, turnStallTimeoutMs);
|
|
1130
|
+
turn.stallTimer.unref?.();
|
|
1131
|
+
};
|
|
1132
|
+
return turn;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
async function runTurn(message) {
|
|
1136
|
+
const model = resolvePerTurnModel(message);
|
|
1137
|
+
const relayMode = message?.relayMode || 'agent';
|
|
1138
|
+
lastRelayMode = relayMode;
|
|
1139
|
+
const turn = createTurn(message);
|
|
1140
|
+
// Set before the session is touched: the heartbeat's owner-recovery guard
|
|
1141
|
+
// reads the active ids, so a cold-start delivery must already own its row.
|
|
1142
|
+
activeTurn = turn;
|
|
1143
|
+
|
|
1144
|
+
const controlState = controlPoller?.start?.({
|
|
1145
|
+
queueMessageId: message.id,
|
|
1146
|
+
onAbortTurn: async () => {
|
|
1147
|
+
turn.aborted = true;
|
|
1148
|
+
// While `ensureSession` is still connecting there is no session to
|
|
1149
|
+
// abort and this would be a silent no-op; `runTurn` re-checks
|
|
1150
|
+
// `turn.aborted` once the session exists and settles there instead.
|
|
1151
|
+
if (!session) return;
|
|
1152
|
+
// The runtime answers an abort with `abort` → `agent.interrupted` →
|
|
1153
|
+
// `assistant.turn_end` → `session.idle{aborted:true}`, which settles
|
|
1154
|
+
// the turn through the normal terminator; this only asks for it.
|
|
1155
|
+
await session.abort?.();
|
|
1156
|
+
},
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
try {
|
|
1160
|
+
await ensureSession(model, relayMode);
|
|
1161
|
+
if (turn.aborted) {
|
|
1162
|
+
// The abort landed while the session was still being built. Nothing
|
|
1163
|
+
// was sent, so there is no runtime turn to interrupt — ask anyway (a
|
|
1164
|
+
// queued prompt from a previous delivery could still be running) and
|
|
1165
|
+
// settle locally rather than sending a prompt the user just cancelled.
|
|
1166
|
+
dbg('turn aborted before send', message.id);
|
|
1167
|
+
try { await session?.abort?.(); } catch (error) {
|
|
1168
|
+
dbg('abort during session setup failed', error?.message || String(error));
|
|
1169
|
+
}
|
|
1170
|
+
turn.settle();
|
|
1171
|
+
} else {
|
|
1172
|
+
turn.armStall();
|
|
1173
|
+
const sendMode = String(resolveSendModeImpl(message) || '').trim();
|
|
1174
|
+
const { prompt: body, attachments } = buildMessageOptionsImpl(message);
|
|
1175
|
+
// Relay mode marker + (on a mode change) the standing mode instructions,
|
|
1176
|
+
// the relay tool guidance and the live preview-lane block.
|
|
1177
|
+
const prefix = await buildRelayContextPrefix(message).catch(() => '');
|
|
1178
|
+
const prompt = withRelayContext(prefix, body);
|
|
1179
|
+
// `mode` and `attachments` are FIELDS of the single MessageOptions
|
|
1180
|
+
// argument — `send()` takes no second parameter, so passing options
|
|
1181
|
+
// positionally drops them silently.
|
|
1182
|
+
await session.send({
|
|
1183
|
+
prompt,
|
|
1184
|
+
...(attachments?.length ? { attachments } : {}),
|
|
1185
|
+
...(sendMode ? { mode: sendMode } : {}),
|
|
1186
|
+
agentMode: copilotAgentModeForRelayMode(relayMode),
|
|
1187
|
+
});
|
|
1188
|
+
// `send()` resolves once the runtime accepted the prompt — it is NOT
|
|
1189
|
+
// the turn's completion; the event stream is.
|
|
1190
|
+
await turn.done;
|
|
1191
|
+
}
|
|
1192
|
+
} finally {
|
|
1193
|
+
controlPoller?.stop?.(controlState);
|
|
1194
|
+
await quiesceTurn(turn);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
return finishTurn(turn, model);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Everything that must happen between "the turn stopped producing events" and
|
|
1202
|
+
* "its state may be read": stop the watchdog, release anything blocked on a
|
|
1203
|
+
* question card, drain in-flight relay POSTs, and close subagent runs the
|
|
1204
|
+
* normalizer never got to close.
|
|
1205
|
+
*/
|
|
1206
|
+
async function quiesceTurn(turn) {
|
|
1207
|
+
turn.disarmStall();
|
|
1208
|
+
// The card's answer can no longer reach the runtime.
|
|
1209
|
+
turn.abortController.abort();
|
|
1210
|
+
// Drain in-flight dispatches before the turn's state is read, so a stream
|
|
1211
|
+
// POST cannot land after the response.
|
|
1212
|
+
await dispatchChain.catch(() => {});
|
|
1213
|
+
// Any subagent still open at this point never will be. The normalizer
|
|
1214
|
+
// closes strays when it produces a terminal result; these are the paths
|
|
1215
|
+
// that never produced one.
|
|
1216
|
+
await closeStraySubagentRuns(turn).catch(() => {});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Publish a finished turn's outcome onto its queue row (and every row steered
|
|
1221
|
+
* into it).
|
|
1222
|
+
*
|
|
1223
|
+
* Shared by both turn kinds. A continuation reaches it through
|
|
1224
|
+
* `driveContinuation` instead of `runTurn`, but the outcomes are identical:
|
|
1225
|
+
* the runtime does not distinguish a turn it started from one it was asked
|
|
1226
|
+
* for, so neither does the reporting.
|
|
1227
|
+
*/
|
|
1228
|
+
async function finishTurn(turn, model) {
|
|
1229
|
+
const { message } = turn;
|
|
1230
|
+
const result = turn.result;
|
|
1231
|
+
const responseModel = result?.model || turn.normalizer.model || model || null;
|
|
1232
|
+
// Capture only. The POST fires from `handlePendingPayload`'s `finally`,
|
|
1233
|
+
// after this row has been published.
|
|
1234
|
+
captureTurnUsage(message, result);
|
|
1235
|
+
|
|
1236
|
+
// Whatever this interaction did not get to stays queued in the runtime and
|
|
1237
|
+
// shifts the NEXT interaction's segments: the prompts this turn sent occupy
|
|
1238
|
+
// `[firstSentSegment, nextSegmentIndex)`, and the ones the runtime never
|
|
1239
|
+
// opened a segment for are still in its pending queue.
|
|
1240
|
+
//
|
|
1241
|
+
// The `max` against `firstSentSegment` is what makes this correct for a
|
|
1242
|
+
// continuation, which occupies segment 0 without having sent anything: a
|
|
1243
|
+
// continuation that opened no segment at all (an empty self-initiated turn)
|
|
1244
|
+
// would otherwise report one carried prompt and shift the next real turn's
|
|
1245
|
+
// answer by one.
|
|
1246
|
+
const segmentsOpened = turn.normalizer.promptCount();
|
|
1247
|
+
carriedPrompts = Math.max(0, turn.nextSegmentIndex - Math.max(turn.firstSentSegment, segmentsOpened));
|
|
1248
|
+
|
|
1249
|
+
// This row's own reply. Falls back to the whole composed text when the
|
|
1250
|
+
// runtime opened no segment at all (an empty or immediately-failed turn),
|
|
1251
|
+
// which is strictly better than publishing nothing.
|
|
1252
|
+
const ownText = String(
|
|
1253
|
+
turn.normalizer.segmentText(turn.baseSegment) || result?.text || turn.state.lastStreamedText || '',
|
|
1254
|
+
).trim();
|
|
1255
|
+
|
|
1256
|
+
// A user-initiated abort publishes the partial text and nothing else: the
|
|
1257
|
+
// queue row's fate belongs to the server-side abort control, exactly as in
|
|
1258
|
+
// the Claude and Cursor workers. Publishing a response here would
|
|
1259
|
+
// double-settle the row.
|
|
1260
|
+
if (turn.aborted) {
|
|
1261
|
+
dbg('turn aborted', message.id);
|
|
1262
|
+
// This row's segment only: the steered rows publish their own, and
|
|
1263
|
+
// publishing the composed text here would show their replies twice.
|
|
1264
|
+
await publishFinalStream(message, ownText);
|
|
1265
|
+
// A steered row is NOT covered by the abort control (which knows only
|
|
1266
|
+
// about the row the user aborted), so it still has to be settled here or
|
|
1267
|
+
// it holds `processing` forever behind a renewing lease.
|
|
1268
|
+
await settleSteeredRows(turn, result, responseModel);
|
|
1269
|
+
return true;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// The runtime aborted on its own (`result.aborted` with no relay-side
|
|
1273
|
+
// abort control in flight). Nothing server-side is waiting to settle this
|
|
1274
|
+
// row, so returning here would leave it pending until the delivery
|
|
1275
|
+
// watchdog fails it with a misleading "Relay timeout" — the row has to be
|
|
1276
|
+
// settled with a record that says what actually happened.
|
|
1277
|
+
if (result?.aborted) {
|
|
1278
|
+
dbg('turn interrupted by the runtime', message.id);
|
|
1279
|
+
const text = ownText ? `${ownText}\n\n${RUNTIME_INTERRUPTED_NOTE}` : RUNTIME_INTERRUPTED_NOTE;
|
|
1280
|
+
await publishFinalStream(message, text);
|
|
1281
|
+
await publishResponse(message, { text, model: responseModel });
|
|
1282
|
+
await settleSteeredRows(turn, result, responseModel);
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
if (result?.isError) {
|
|
1287
|
+
const classified = classifyCopilotSessionError(result.errorData || { message: result.errorMessage });
|
|
1288
|
+
dbg('turn failed', message.id, classified.stableCode);
|
|
1289
|
+
await publishFinalStream(message, turn.state.lastStreamedText);
|
|
1290
|
+
await publishResponse(message, {
|
|
1291
|
+
text: classified.text,
|
|
1292
|
+
model: responseModel,
|
|
1293
|
+
terminalError: terminalErrorRecord(message, classified),
|
|
1294
|
+
});
|
|
1295
|
+
// The steered rows failed with it — they were being answered by the same
|
|
1296
|
+
// interaction. They get the same terminal record so each row says why.
|
|
1297
|
+
await settleSteeredRows(turn, result, responseModel, classified);
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// The turn produced a plan but never called exit-plan-mode (or the runtime
|
|
1302
|
+
// build has no such hook). Same text-shape fallback the siblings use, and
|
|
1303
|
+
// it must go out BEFORE the response: `/api/relay-board` 409s once the
|
|
1304
|
+
// queue row leaves `processing`.
|
|
1305
|
+
if (shouldPostPlanBoard({
|
|
1306
|
+
relayMode: message.relayMode,
|
|
1307
|
+
finalText: ownText,
|
|
1308
|
+
alreadyPosted: turn.planBoardPosted,
|
|
1309
|
+
acted: turn.acted === true,
|
|
1310
|
+
})) {
|
|
1311
|
+
await publishPlanBoard(turn, ownText, 'plan-mode-fallback');
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// A terminal, non-error turn with no prose is COMPLETE, not a failed
|
|
1315
|
+
// delivery — requeuing re-runs deterministically empty work until the
|
|
1316
|
+
// retry cap fails the row with a misleading "Relay timeout".
|
|
1317
|
+
const publishedText = ownText || EMPTY_TURN_COMPLETION_NOTE;
|
|
1318
|
+
await publishFinalStream(message, publishedText);
|
|
1319
|
+
await publishResponse(message, { text: publishedText, model: responseModel });
|
|
1320
|
+
await settleSteeredRows(turn, result, responseModel);
|
|
1321
|
+
return true;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// ----------------------------------------------------------- continuation --
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* The runtime started a turn nobody asked for. Give it a relay row.
|
|
1328
|
+
*
|
|
1329
|
+
* Called synchronously from `routeEvent`, so `activeTurn` is set before the
|
|
1330
|
+
* triggering event is dispatched — which is what stops a second opener in the
|
|
1331
|
+
* same batch from minting a second row for the same turn, and what makes idle
|
|
1332
|
+
* shutdown and the heartbeat see the work immediately.
|
|
1333
|
+
*
|
|
1334
|
+
* The row itself is created asynchronously (`POST /api/continuation-turn`);
|
|
1335
|
+
* everything the turn produces meanwhile buffers on the turn and flushes in
|
|
1336
|
+
* order once the row has an id.
|
|
1337
|
+
*/
|
|
1338
|
+
function openContinuationTurn() {
|
|
1339
|
+
const turn = createTurn({
|
|
1340
|
+
id: null,
|
|
1341
|
+
conversationId: sdkSessionId,
|
|
1342
|
+
// A self-initiated turn has no delivery to read a mode off, so it runs in
|
|
1343
|
+
// the mode the conversation was last driven in — the permission handler
|
|
1344
|
+
// and the plan-board gating both read the live turn's mode.
|
|
1345
|
+
relayMode: lastRelayMode,
|
|
1346
|
+
model: '',
|
|
1347
|
+
}, { kind: 'continuation' });
|
|
1348
|
+
activeTurn = turn;
|
|
1349
|
+
// The gap this pin covers has closed.
|
|
1350
|
+
continuationDueSince = 0;
|
|
1351
|
+
turn.armStall();
|
|
1352
|
+
// Lines produced between turns (a settled shell's notification) belong to
|
|
1353
|
+
// the turn they triggered.
|
|
1354
|
+
if (pendingActivities.length) {
|
|
1355
|
+
for (const text of pendingActivities.splice(0)) {
|
|
1356
|
+
turn.bufferedActions.push({ channel: 'activity', payload: { text, subagentRunId: null } });
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
dbg('opening a continuation turn for runtime-initiated work');
|
|
1360
|
+
// Both are fire-and-forget by design (the SDK's event callback is
|
|
1361
|
+
// synchronous and cannot await a turn), so both must swallow: an unhandled
|
|
1362
|
+
// rejection here would reach the worker crash guard and take the whole
|
|
1363
|
+
// process down over one lost continuation.
|
|
1364
|
+
registerContinuationRow(turn).catch((error) => {
|
|
1365
|
+
dbg('continuation registration threw', error?.message || String(error));
|
|
1366
|
+
if (turn.message.id) {
|
|
1367
|
+
// The row was created and only the bookkeeping after it failed. Release
|
|
1368
|
+
// the buffer to the drive path rather than throwing away a turn that
|
|
1369
|
+
// has somewhere to go.
|
|
1370
|
+
turn.registered = true;
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
turn.discarded = true;
|
|
1374
|
+
turn.bufferedActions = [];
|
|
1375
|
+
});
|
|
1376
|
+
driveContinuation(turn).catch((error) => {
|
|
1377
|
+
dbg('continuation driver threw', error?.message || String(error));
|
|
1378
|
+
});
|
|
1379
|
+
return turn;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/**
|
|
1383
|
+
* Create the synthetic queue row and release the turn's buffered output.
|
|
1384
|
+
*
|
|
1385
|
+
* Retries on any response that produced no message id — a truthy but empty
|
|
1386
|
+
* body must not end the loop early. Giving up discards the turn's relay
|
|
1387
|
+
* output (it still lands in the runtime's own transcript) rather than failing
|
|
1388
|
+
* the worker: a continuation nobody can see is a lost message, not a broken
|
|
1389
|
+
* session.
|
|
1390
|
+
*/
|
|
1391
|
+
async function registerContinuationRow(turn) {
|
|
1392
|
+
let response = null;
|
|
1393
|
+
for (let attempt = 0; attempt < 3 && !response?.messageId; attempt += 1) {
|
|
1394
|
+
if (turn.discarded) return;
|
|
1395
|
+
response = await api('POST', '/api/continuation-turn', {
|
|
1396
|
+
conversationId: sdkSessionId,
|
|
1397
|
+
sdkSessionId,
|
|
1398
|
+
relayMode: turn.message.relayMode,
|
|
1399
|
+
trigger: CONTINUATION_TRIGGER,
|
|
1400
|
+
}).catch((error) => {
|
|
1401
|
+
dbg('continuation turn registration failed', error?.message || String(error));
|
|
1402
|
+
return null;
|
|
1403
|
+
});
|
|
1404
|
+
if (!response?.messageId) {
|
|
1405
|
+
await new Promise((resolve) => { setTimeout(resolve, continuationRetryDelayMs); });
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
if (!response?.messageId) {
|
|
1409
|
+
turn.discarded = true;
|
|
1410
|
+
turn.bufferedActions = [];
|
|
1411
|
+
dbg('continuation turn discarded (no relay message id)');
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
turn.message.id = String(response.messageId);
|
|
1415
|
+
// The route reports which conversation the synthetic row landed on;
|
|
1416
|
+
// trusting it beats assuming worker session id === conversation id.
|
|
1417
|
+
const conversationId = String(response.conversationId || '').trim();
|
|
1418
|
+
if (conversationId) turn.message.conversationId = conversationId;
|
|
1419
|
+
// Only now is there a row to abort, so this is where the control poller can
|
|
1420
|
+
// start.
|
|
1421
|
+
turn.controlState = controlPoller?.start?.({
|
|
1422
|
+
queueMessageId: turn.message.id,
|
|
1423
|
+
onAbortTurn: async () => {
|
|
1424
|
+
turn.aborted = true;
|
|
1425
|
+
if (!session) return;
|
|
1426
|
+
await session.abort?.();
|
|
1427
|
+
},
|
|
1428
|
+
}) || null;
|
|
1429
|
+
await flushBufferedActions(turn);
|
|
1430
|
+
turn.registered = true;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Resolve once the continuation's row exists, or its registration gave up. */
|
|
1434
|
+
async function awaitContinuationRow(turn, timeoutMs = continuationRegistrationTimeoutMs) {
|
|
1435
|
+
const deadline = Date.now() + timeoutMs;
|
|
1436
|
+
while (!turn.registered && !turn.discarded && Date.now() < deadline) {
|
|
1437
|
+
await new Promise((resolve) => { setTimeout(resolve, 25); });
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Run a continuation to its terminator and publish it, mirroring
|
|
1443
|
+
* `handlePendingPayload`'s outer shape.
|
|
1444
|
+
*
|
|
1445
|
+
* `activeTurn` is cleared only after every publish has landed, exactly as for
|
|
1446
|
+
* a delivered turn: a heartbeat firing inside the publish window with no
|
|
1447
|
+
* active ids would tell the relay this worker owns nothing, and the
|
|
1448
|
+
* still-`processing` synthetic row would be recovered underneath it.
|
|
1449
|
+
*/
|
|
1450
|
+
async function driveContinuation(turn) {
|
|
1451
|
+
let failure = null;
|
|
1452
|
+
try {
|
|
1453
|
+
await turn.done;
|
|
1454
|
+
} catch (error) {
|
|
1455
|
+
failure = error;
|
|
1456
|
+
}
|
|
1457
|
+
try {
|
|
1458
|
+
await quiesceTurn(turn);
|
|
1459
|
+
// Nothing can be published before the row exists. The buffer is drained
|
|
1460
|
+
// by the registration itself; this only covers actions that arrived
|
|
1461
|
+
// during the drain.
|
|
1462
|
+
await awaitContinuationRow(turn);
|
|
1463
|
+
// The invariant is the id, not the flag: registration can also give up by
|
|
1464
|
+
// throwing, or by taking longer than `awaitContinuationRow` waits, and
|
|
1465
|
+
// publishing against `messageId: null` would attribute the whole turn to
|
|
1466
|
+
// nothing at all.
|
|
1467
|
+
if (!turn.message.id) {
|
|
1468
|
+
turn.discarded = true;
|
|
1469
|
+
turn.bufferedActions = [];
|
|
1470
|
+
dbg('continuation output dropped (no relay row)');
|
|
1471
|
+
// The steering path is still waiting on any row it handed us, and it
|
|
1472
|
+
// has no row of its own to fall back to.
|
|
1473
|
+
await settleSteeredRows(turn, null, null, classifyCopilotTurnException(
|
|
1474
|
+
failure || new Error('the relay refused a continuation row for this turn'),
|
|
1475
|
+
)).catch(() => {});
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
await flushBufferedActions(turn);
|
|
1479
|
+
if (failure) {
|
|
1480
|
+
const classified = classifyCopilotTurnException(failure);
|
|
1481
|
+
dbg('continuation turn failed', turn.message.id, classified.detail);
|
|
1482
|
+
await publishResponse(turn.message, {
|
|
1483
|
+
text: classified.text,
|
|
1484
|
+
model: null,
|
|
1485
|
+
terminalError: terminalErrorRecord(turn.message, classified),
|
|
1486
|
+
});
|
|
1487
|
+
await settleSteeredRows(turn, null, null, classified).catch(() => {});
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
await finishTurn(turn, '');
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
dbg('continuation publish failed', error?.message || String(error));
|
|
1493
|
+
} finally {
|
|
1494
|
+
controlPoller?.stop?.(turn.controlState);
|
|
1495
|
+
turn.controlState = null;
|
|
1496
|
+
if (activeTurn === turn) activeTurn = null;
|
|
1497
|
+
touch();
|
|
1498
|
+
// A continuation spends real quota (the live capture burned a premium
|
|
1499
|
+
// request on `read_bash` + the reply), so its numbers ride the same
|
|
1500
|
+
// fire-and-forget ingest as a delivered turn's.
|
|
1501
|
+
postTurnUsage();
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Settle every queue row that was steered into this interaction.
|
|
1507
|
+
*
|
|
1508
|
+
* The runtime answers the original prompt AND every prompt queued behind it
|
|
1509
|
+
* under ONE `session.idle` (live-verified — see the plan doc §5), so no
|
|
1510
|
+
* second turn will ever settle these rows. Each one is answered with the text
|
|
1511
|
+
* of ITS OWN prompt segment, which the normalizer separates using the
|
|
1512
|
+
* `user.message` events that mark where the runtime picked each prompt up.
|
|
1513
|
+
*
|
|
1514
|
+
* A row whose prompt the runtime accepted but never started work on has no
|
|
1515
|
+
* segment. It is NOT requeued: the prompt is still sitting in the runtime's
|
|
1516
|
+
* pending queue and will be answered at the start of the next turn, so
|
|
1517
|
+
* redelivering it would run the same prompt twice. It gets a note instead —
|
|
1518
|
+
* the same trade the Claude worker makes for a handed-off context.
|
|
1519
|
+
*/
|
|
1520
|
+
async function settleSteeredRows(turn, result, responseModel, classified = null) {
|
|
1521
|
+
if (!turn.steeredRows.length) return;
|
|
1522
|
+
for (const steered of turn.steeredRows) {
|
|
1523
|
+
const { message: steeredMessage, segmentIndex } = steered;
|
|
1524
|
+
let text = String(turn.normalizer.segmentText(segmentIndex) || '').trim();
|
|
1525
|
+
if (classified) {
|
|
1526
|
+
text = classified.text;
|
|
1527
|
+
} else if (!text) {
|
|
1528
|
+
text = STEERED_ROW_MERGED_NOTE;
|
|
1529
|
+
}
|
|
1530
|
+
await publishFinalStream(steeredMessage, text);
|
|
1531
|
+
await publishResponse(steeredMessage, {
|
|
1532
|
+
text,
|
|
1533
|
+
model: responseModel,
|
|
1534
|
+
...(classified ? { terminalError: terminalErrorRecord(steeredMessage, classified) } : {}),
|
|
1535
|
+
});
|
|
1536
|
+
steered.settle?.();
|
|
1537
|
+
}
|
|
1538
|
+
turn.steeredRows.length = 0;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* A delivery arrived while a turn was already running.
|
|
1543
|
+
*
|
|
1544
|
+
* The relay's worker socket is single-flight — it will not deliver a second
|
|
1545
|
+
* row until `onDeliver` resolves — so this is not the normal path. It is
|
|
1546
|
+
* reachable on a socket reconnect that redelivers, and it is the path a
|
|
1547
|
+
* future relay change would take, so it steers rather than corrupting the
|
|
1548
|
+
* turn: the prompt is queued into the SAME interaction (which is all
|
|
1549
|
+
* `mode: "enqueue"` can do — it cannot interrupt an in-flight model call),
|
|
1550
|
+
* and the row is adopted so the interaction's terminator settles it too.
|
|
1551
|
+
*
|
|
1552
|
+
* The row is registered BEFORE the send so a failure between the two cannot
|
|
1553
|
+
* leave a row nobody owns.
|
|
1554
|
+
*/
|
|
1555
|
+
async function steerIntoActiveTurn(turn, message) {
|
|
1556
|
+
const { prompt: body, attachments } = buildMessageOptionsImpl(message);
|
|
1557
|
+
// A real relay round trip on the first turn of a mode — long enough for the
|
|
1558
|
+
// turn to finish underneath us.
|
|
1559
|
+
const prefix = await buildRelayContextPrefix(message).catch(() => '');
|
|
1560
|
+
const prompt = withRelayContext(prefix, body);
|
|
1561
|
+
// Re-checked AFTER the awaits and before anything is sent or registered.
|
|
1562
|
+
// `settleSteeredRows` has already run if the turn settled during them, so a
|
|
1563
|
+
// row pushed now would never be settled and its caller would wait forever —
|
|
1564
|
+
// wedging the single-flight delivery socket. Nothing has been sent yet, so
|
|
1565
|
+
// handing the row back to the normal path is free.
|
|
1566
|
+
if (turn.settled) {
|
|
1567
|
+
dbg('turn settled while steering was preparing; running it as a fresh turn', message.id);
|
|
1568
|
+
return NOT_STEERED;
|
|
1569
|
+
}
|
|
1570
|
+
// Prompts are consumed in the order they were sent, and each opens its own
|
|
1571
|
+
// `user.message` segment as it is picked up, so this prompt's send position
|
|
1572
|
+
// (after any prompts carried over from a previous interaction, and after
|
|
1573
|
+
// whatever the interaction itself occupies) is its segment index.
|
|
1574
|
+
const segmentIndex = turn.nextSegmentIndex;
|
|
1575
|
+
turn.nextSegmentIndex += 1;
|
|
1576
|
+
turn.promptsSent += 1;
|
|
1577
|
+
const steered = { message, segmentIndex };
|
|
1578
|
+
steered.done = new Promise((resolve) => { steered.settle = resolve; });
|
|
1579
|
+
turn.steeredRows.push(steered);
|
|
1580
|
+
dbg('steering a mid-turn delivery into the running turn', message.id, `segment=${segmentIndex}`);
|
|
1581
|
+
try {
|
|
1582
|
+
await session.send({
|
|
1583
|
+
prompt,
|
|
1584
|
+
...(attachments?.length ? { attachments } : {}),
|
|
1585
|
+
mode: 'enqueue',
|
|
1586
|
+
agentMode: copilotAgentModeForRelayMode(message?.relayMode || 'agent'),
|
|
1587
|
+
});
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
// The prompt never reached the runtime, so nothing will answer it and the
|
|
1590
|
+
// row is safe to requeue — unlike an accepted one.
|
|
1591
|
+
turn.steeredRows = turn.steeredRows.filter((entry) => entry !== steered);
|
|
1592
|
+
dbg('steering send failed, requeuing the row', message.id, error?.message || String(error));
|
|
1593
|
+
await api('POST', '/api/requeue', { messageId: message.id }).catch(() => {});
|
|
1594
|
+
return true;
|
|
1595
|
+
}
|
|
1596
|
+
// Resolves when the interaction settles this row in `settleSteeredRows`.
|
|
1597
|
+
await steered.done;
|
|
1598
|
+
return true;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function handlePendingPayload(pending) {
|
|
1602
|
+
const message = pending?.message || null;
|
|
1603
|
+
if (!message) return false;
|
|
1604
|
+
// A delivery that lands while a turn is running is steered into it rather
|
|
1605
|
+
// than starting a second one: the runtime has a single conversation and a
|
|
1606
|
+
// concurrent `send` would interleave into the same interaction anyway —
|
|
1607
|
+
// this way the row is owned and settled instead of orphaned.
|
|
1608
|
+
//
|
|
1609
|
+
// Deliberately OUTSIDE the try/finally below: that `finally` clears
|
|
1610
|
+
// `activeTurn`, and a steered call returns while the turn it was steered
|
|
1611
|
+
// into is still publishing. Clearing there would tell the heartbeat this
|
|
1612
|
+
// worker owns nothing and the relay would recover the live row.
|
|
1613
|
+
if (activeTurn && !activeTurn.settled && session) {
|
|
1614
|
+
const turn = activeTurn;
|
|
1615
|
+
try {
|
|
1616
|
+
const outcome = await steerIntoActiveTurn(turn, message);
|
|
1617
|
+
if (outcome !== NOT_STEERED) return outcome;
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
dbg('steering failed', message.id, error?.message || String(error));
|
|
1620
|
+
const classified = classifyCopilotTurnException(error);
|
|
1621
|
+
await publishResponse(message, {
|
|
1622
|
+
text: classified.text,
|
|
1623
|
+
model: null,
|
|
1624
|
+
terminalError: terminalErrorRecord(message, classified),
|
|
1625
|
+
});
|
|
1626
|
+
return true;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
try {
|
|
1630
|
+
return await runTurn(message);
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
const classified = classifyCopilotTurnException(error);
|
|
1633
|
+
dbg('copilot turn failed', message.id, classified.detail);
|
|
1634
|
+
// A failure that killed the session (or came from starting it) leaves a
|
|
1635
|
+
// handle nothing else can use; drop it so the next delivery rebuilds and
|
|
1636
|
+
// resumes rather than sending into a dead runtime.
|
|
1637
|
+
await stopRuntime('turn-failure').catch(() => {});
|
|
1638
|
+
await publishResponse(message, {
|
|
1639
|
+
text: classified.text,
|
|
1640
|
+
model: null,
|
|
1641
|
+
terminalError: terminalErrorRecord(message, classified),
|
|
1642
|
+
});
|
|
1643
|
+
// Rows steered into the turn that just threw are owed a response too —
|
|
1644
|
+
// and, more urgently, `steerIntoActiveTurn` is still awaiting their
|
|
1645
|
+
// settle. Skipping this would wedge that caller (and its queue row)
|
|
1646
|
+
// forever behind a heartbeat that keeps renewing the lease.
|
|
1647
|
+
if (activeTurn) {
|
|
1648
|
+
await settleSteeredRows(activeTurn, null, null, classified).catch(() => {});
|
|
1649
|
+
await closeStraySubagentRuns(activeTurn).catch(() => {});
|
|
1650
|
+
}
|
|
1651
|
+
return true;
|
|
1652
|
+
} finally {
|
|
1653
|
+
// Cleared only once every publish for this row has landed. A heartbeat
|
|
1654
|
+
// that fired during the publish window with no active ids would tell the
|
|
1655
|
+
// relay this worker owns nothing, and the still-processing row would be
|
|
1656
|
+
// recovered (`owner-heartbeat-idle`) and re-delivered — a duplicate
|
|
1657
|
+
// execution racing the response that was already on its way.
|
|
1658
|
+
activeTurn = null;
|
|
1659
|
+
touch();
|
|
1660
|
+
// Every path through the turn — published, failed, aborted, threw — has
|
|
1661
|
+
// finished by here, which is the only safe place for the ingest: it is
|
|
1662
|
+
// advisory, it is not awaited, and it must never be able to delay a reply
|
|
1663
|
+
// that is already written.
|
|
1664
|
+
postTurnUsage();
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// --------------------------------------------------------------- teardown --
|
|
1669
|
+
|
|
1670
|
+
function getActiveQueueMessageId() {
|
|
1671
|
+
return activeTurn ? String(activeTurn.message?.id || '') : '';
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Every row this worker owns — the turn's own, plus any steered into it.
|
|
1676
|
+
*
|
|
1677
|
+
* Both the heartbeat (lease renewal) and the crash guard (requeue-on-exit)
|
|
1678
|
+
* read this. A steered row missing from it would be recovered mid-flight as
|
|
1679
|
+
* `owner-heartbeat-mismatch` and re-delivered while the runtime was still
|
|
1680
|
+
* answering it.
|
|
1681
|
+
*/
|
|
1682
|
+
function getActiveQueueMessageIds() {
|
|
1683
|
+
if (!activeTurn) return [];
|
|
1684
|
+
const ids = [];
|
|
1685
|
+
const primary = String(activeTurn.message?.id || '');
|
|
1686
|
+
if (primary) ids.push(primary);
|
|
1687
|
+
for (const steered of activeTurn.steeredRows || []) {
|
|
1688
|
+
const id = String(steered.message?.id || '');
|
|
1689
|
+
if (id && !ids.includes(id)) ids.push(id);
|
|
1690
|
+
}
|
|
1691
|
+
return ids;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
async function dispose() {
|
|
1695
|
+
disposed = true;
|
|
1696
|
+
stopLifecycleTimer();
|
|
1697
|
+
// A question card left `pending` would sit in the UI inviting an answer
|
|
1698
|
+
// that nothing is left to read. Time them out before the socket goes.
|
|
1699
|
+
await questionBridge.cancelPendingQuestions?.().catch?.(() => {});
|
|
1700
|
+
await stopRuntime('worker-shutdown');
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
return {
|
|
1704
|
+
handlePendingPayload,
|
|
1705
|
+
getActiveQueueMessageId,
|
|
1706
|
+
getActiveQueueMessageIds,
|
|
1707
|
+
isTurnActive: () => !!activeTurn,
|
|
1708
|
+
dispose,
|
|
1709
|
+
// The turn's tokens/cost/TTFT, as posted to `/api/copilot-plan-usage`.
|
|
1710
|
+
getLastTurnUsage: () => lastTurnUsage,
|
|
1711
|
+
// The in-flight usage ingest. A test seam ONLY: the turn path deliberately
|
|
1712
|
+
// never awaits this, which is what keeps a slow relay from holding a
|
|
1713
|
+
// finished reply.
|
|
1714
|
+
whenUsagePosted: () => usagePostChain,
|
|
1715
|
+
// Test seams / observability.
|
|
1716
|
+
_getState: () => ({
|
|
1717
|
+
hasClient: !!client,
|
|
1718
|
+
hasSession: !!session,
|
|
1719
|
+
appliedModel,
|
|
1720
|
+
lastActivityAt,
|
|
1721
|
+
// 'delivered' | 'continuation' | '' — which kind of turn, if any, owns
|
|
1722
|
+
// the worker right now.
|
|
1723
|
+
activeTurnKind: activeTurn?.kind || '',
|
|
1724
|
+
backgroundShells: backgroundShells.live(),
|
|
1725
|
+
continuationDueSince,
|
|
1726
|
+
}),
|
|
1727
|
+
_evaluateLifecycle: evaluateLifecycle,
|
|
1728
|
+
};
|
|
1729
|
+
}
|