@oar-sh/oar 0.9.4 → 0.9.6
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/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +199 -0
- package/README.md +35 -6
- package/package.json +2 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +411 -19
- package/server/claude-worker/claude-session-worker.mjs +5 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-session-worker.mjs +5 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +7 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-session-worker.mjs +5 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/public/app/api-client.js +17 -2
- package/server/public/app/bootstrap.js +3 -0
- package/server/public/app/composer-control-state.mjs +4 -3
- package/server/public/app/conversation-view.js +97 -4
- package/server/public/app/journal-view.js +6 -0
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +12 -3
- package/server/public/app/socket-handlers.js +9 -0
- package/server/public/app/steered-turn-merge.mjs +10 -1
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +97 -4
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +30 -0
- package/server/routes/messages-routes.mjs +93 -17
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/sessions-routes.mjs +40 -2
- package/server/server-runtime.mjs +33 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +45 -20
- package/server/services/remote-relay-dispatcher.mjs +328 -21
- package/server/services/remote-relay-inbound.mjs +35 -0
- package/server/services/session-worker-launch-service.mjs +17 -2
- package/server/services/session-worker-process-service.mjs +85 -32
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-supervisor-service.mjs +33 -1
- package/server/services/usage-limit-pause-service.mjs +441 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +33 -2
- package/shared/steer-settle-failure.mjs +22 -0
- package/shared/steer-settle-markers.mjs +24 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
- package/shared/worker-runtime/worker-log-file.mjs +97 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Why a Claude turn failed: the code the relay prints, and the advice that
|
|
2
|
+
// fits the failure.
|
|
3
|
+
//
|
|
4
|
+
// A turn the API refused ends with a `result` of subtype 'success' and
|
|
5
|
+
// `is_error: true`: the subtype says how the turn ended, not whether it
|
|
6
|
+
// worked, so it names nothing. What names the failure, in this order:
|
|
7
|
+
//
|
|
8
|
+
// 1. the `error` field of the turn's last assistant message (the SDK's
|
|
9
|
+
// SDKAssistantMessageError: 'billing_error', 'oauth_org_not_allowed', …);
|
|
10
|
+
// 2. the `api_error_status` of the result;
|
|
11
|
+
// 3. the words of the refusal, for an account failure that came with
|
|
12
|
+
// neither;
|
|
13
|
+
// 4. a subtype other than 'success' ('error_max_turns', …);
|
|
14
|
+
// 5. the `terminal_reason` of the result.
|
|
15
|
+
//
|
|
16
|
+
// The relay prints `claude.<code>` as `relay.claude-<code>`, dashes for
|
|
17
|
+
// underscores.
|
|
18
|
+
|
|
19
|
+
// The user has to sign in again: a retry cannot succeed before that.
|
|
20
|
+
const SIGN_IN_ERRORS = new Set(['authentication_failed']);
|
|
21
|
+
// The account itself refuses: billing, or access its organisation withholds.
|
|
22
|
+
const ACCOUNT_ERRORS = new Set([
|
|
23
|
+
'billing_error',
|
|
24
|
+
'oauth_org_not_allowed',
|
|
25
|
+
'account_on_hold',
|
|
26
|
+
'verification_required',
|
|
27
|
+
'account-access',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const STATUS_CODES = new Map([
|
|
31
|
+
[401, 'authentication_failed'],
|
|
32
|
+
[402, 'billing_error'],
|
|
33
|
+
[429, 'rate_limit'],
|
|
34
|
+
[529, 'overloaded'],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Checked before the sign-in words: the organisation's refusal mentions an
|
|
38
|
+
// API key too.
|
|
39
|
+
const ACCOUNT_ACCESS_TEXT = /organi[sz]ation has disabled|subscription access|does not have access to claude/i;
|
|
40
|
+
const BILLING_TEXT = /credit balance|billing|payment/i;
|
|
41
|
+
const SIGN_IN_TEXT = /failed to authenticate|authentication_error|invalid api key|not logged in|please run \/login/i;
|
|
42
|
+
|
|
43
|
+
export const CLAUDE_SIGN_IN_GUIDANCE =
|
|
44
|
+
'Sign in to Claude again in Settings → Providers → Claude → Relogin (or run `claude` on the relay host), '
|
|
45
|
+
+ 'then send the message again.';
|
|
46
|
+
export const CLAUDE_ACCOUNT_GUIDANCE =
|
|
47
|
+
'Check the Claude account the relay host is signed in to: its subscription, its payment method, and whether '
|
|
48
|
+
+ 'its organisation allows Claude Code. Settings → Providers → Claude shows the account and signs in to '
|
|
49
|
+
+ 'another one. Then send the message again.';
|
|
50
|
+
export const CLAUDE_MODEL_GUIDANCE = 'Choose another model for this conversation, then send the message again.';
|
|
51
|
+
|
|
52
|
+
function codeFromText(text) {
|
|
53
|
+
if (ACCOUNT_ACCESS_TEXT.test(text)) return 'account-access';
|
|
54
|
+
if (BILLING_TEXT.test(text)) return 'billing_error';
|
|
55
|
+
if (SIGN_IN_TEXT.test(text)) return 'authentication_failed';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The advice for a code, or null where the relay's own default fits. */
|
|
60
|
+
export function claudeFailureGuidance(code) {
|
|
61
|
+
const token = String(code || '').trim().toLowerCase();
|
|
62
|
+
if (SIGN_IN_ERRORS.has(token)) return CLAUDE_SIGN_IN_GUIDANCE;
|
|
63
|
+
if (ACCOUNT_ERRORS.has(token)) return CLAUDE_ACCOUNT_GUIDANCE;
|
|
64
|
+
if (token === 'model_not_found') return CLAUDE_MODEL_GUIDANCE;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `result` is the normalized result payload of the turn (sdk-message-
|
|
70
|
+
* normalizer): `{ text, subtype, assistantError, apiErrorStatus,
|
|
71
|
+
* terminalReason }`. Returns `{ code, stableCode, guidance }`; `guidance` is
|
|
72
|
+
* null where the relay's default fits.
|
|
73
|
+
*/
|
|
74
|
+
export function classifyClaudeResultFailure(result = {}) {
|
|
75
|
+
const subtype = String(result?.subtype || '').trim();
|
|
76
|
+
const assistantError = String(result?.assistantError || '').trim().toLowerCase();
|
|
77
|
+
const status = Number(result?.apiErrorStatus);
|
|
78
|
+
const hasStatus = Number.isFinite(status) && status >= 400;
|
|
79
|
+
const terminalReason = String(result?.terminalReason || '').trim().toLowerCase();
|
|
80
|
+
|
|
81
|
+
let code = null;
|
|
82
|
+
if (assistantError && assistantError !== 'unknown') code = assistantError;
|
|
83
|
+
else if (hasStatus && STATUS_CODES.has(status)) code = STATUS_CODES.get(status);
|
|
84
|
+
else code = codeFromText(String(result?.text || ''));
|
|
85
|
+
|
|
86
|
+
if (!code && subtype && subtype !== 'success') code = subtype;
|
|
87
|
+
if (!code && (hasStatus || assistantError === 'unknown' || terminalReason === 'api_error')) code = 'api-error';
|
|
88
|
+
if (!code && terminalReason && terminalReason !== 'completed') code = terminalReason;
|
|
89
|
+
if (!code) code = 'turn-error';
|
|
90
|
+
|
|
91
|
+
return { code, stableCode: `claude.${code}`, guidance: claudeFailureGuidance(code) };
|
|
92
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
+
import { classifyClaudeResultFailure, claudeFailureGuidance } from './claude-turn-failure.mjs';
|
|
2
3
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
3
4
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
4
5
|
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
6
|
+
import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
|
|
5
7
|
|
|
6
8
|
const PLAN_BOARD_ACTIONS = [
|
|
7
9
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -178,6 +180,18 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
178
180
|
});
|
|
179
181
|
}
|
|
180
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Ship what the CLI reported about the subscription's usage limit: the
|
|
185
|
+
* relay warns before the limit and shows the reset once it is reached.
|
|
186
|
+
* Advisory, and fired without waiting: it must not hold up the stream.
|
|
187
|
+
*/
|
|
188
|
+
function publishUsageLimitReport({ conversationId, sdkSessionId, report }) {
|
|
189
|
+
if (!report) return;
|
|
190
|
+
api('POST', '/api/claude-usage-limit', { conversationId, sdkSessionId, report }).catch((error) => {
|
|
191
|
+
dbg('usage limit report publish failed', error?.message || String(error));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
181
195
|
async function publishFinalStream(message, text) {
|
|
182
196
|
await api('POST', '/api/stream', {
|
|
183
197
|
messageId: message.id,
|
|
@@ -204,6 +218,9 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
204
218
|
text,
|
|
205
219
|
model,
|
|
206
220
|
terminalError = null,
|
|
221
|
+
// What the turn had written before it failed; the relay keeps it above
|
|
222
|
+
// the failure or pause note.
|
|
223
|
+
partialText = '',
|
|
207
224
|
modelOrigin,
|
|
208
225
|
absorbed = false,
|
|
209
226
|
kind = null,
|
|
@@ -244,6 +261,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
244
261
|
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
245
262
|
...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
|
|
246
263
|
...(terminalError ? { terminalError } : {}),
|
|
264
|
+
...(terminalError && String(partialText || '').trim() ? { partialText: String(partialText).trim() } : {}),
|
|
247
265
|
// The relay stamps the assistant message's kind from these: 'absorbed'
|
|
248
266
|
// renders as merged into the next (steered) user message, 'folded' as
|
|
249
267
|
// a compact marker on its own message, 'stopped' as a steer the
|
|
@@ -333,13 +351,34 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
333
351
|
const errorText = result.text
|
|
334
352
|
|| `Claude turn failed (${result.subtype || 'unknown error'}).`;
|
|
335
353
|
await publishFinalStream(message, state.lastStreamedText);
|
|
354
|
+
// Refused at the usage limit: a relay that knows the kind pauses the turn
|
|
355
|
+
// until the reset instead of failing it.
|
|
356
|
+
if (result.usageLimit) {
|
|
357
|
+
// What the turn had written before it was refused, without the CLI's
|
|
358
|
+
// own line about the limit, which is the last thing in the stream. The
|
|
359
|
+
// relay keeps it above its note: a turn cut off in the middle has
|
|
360
|
+
// usually said what it was doing.
|
|
361
|
+
const refusalText = String(result.usageLimit.text || '').trim();
|
|
362
|
+
let written = String(state.lastStreamedText || '').trim();
|
|
363
|
+
if (refusalText && written.endsWith(refusalText)) written = written.slice(0, -refusalText.length).trim();
|
|
364
|
+
await publishResponse(message, {
|
|
365
|
+
text: errorText,
|
|
366
|
+
model: responseModel,
|
|
367
|
+
terminalError: buildUsageLimitFailure(message, result.usageLimit),
|
|
368
|
+
partialText: written,
|
|
369
|
+
consumedSteerIds,
|
|
370
|
+
});
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
336
373
|
await publishResponse(message, {
|
|
337
374
|
text: errorText,
|
|
338
375
|
model: responseModel,
|
|
339
376
|
terminalError: {
|
|
340
377
|
kind: 'claude-turn-failed',
|
|
341
|
-
|
|
342
|
-
|
|
378
|
+
// Never built from the subtype alone: a refused turn ends with
|
|
379
|
+
// subtype 'success'. An account failure brings its own advice, since
|
|
380
|
+
// sending the message again cannot help before the account is fixed.
|
|
381
|
+
...classifyClaudeResultFailure(result),
|
|
343
382
|
message: errorText,
|
|
344
383
|
failedAt: new Date().toISOString(),
|
|
345
384
|
queueMessageId: String(message.id || '') || null,
|
|
@@ -348,7 +387,15 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
348
387
|
});
|
|
349
388
|
}
|
|
350
389
|
|
|
351
|
-
async function publishTurnException({ message, errorText }) {
|
|
390
|
+
async function publishTurnException({ message, errorText, usageLimit = null }) {
|
|
391
|
+
if (usageLimit) {
|
|
392
|
+
await publishResponse(message, {
|
|
393
|
+
text: usageLimit.text || errorText,
|
|
394
|
+
model: null,
|
|
395
|
+
terminalError: buildUsageLimitFailure(message, usageLimit),
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
352
399
|
const isAuthError = /authentication|logged in|login|credential|api key/i.test(errorText);
|
|
353
400
|
await publishResponse(message, {
|
|
354
401
|
text: isAuthError
|
|
@@ -360,6 +407,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
360
407
|
code: isAuthError ? 'authentication_failed' : 'turn-error',
|
|
361
408
|
stableCode: isAuthError ? 'claude.authentication_failed' : 'claude.turn-error',
|
|
362
409
|
message: errorText,
|
|
410
|
+
...(isAuthError ? { guidance: claudeFailureGuidance('authentication_failed') } : {}),
|
|
363
411
|
failedAt: new Date().toISOString(),
|
|
364
412
|
queueMessageId: String(message.id || '') || null,
|
|
365
413
|
},
|
|
@@ -372,6 +420,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
372
420
|
dispatchAction,
|
|
373
421
|
publishContextUsage,
|
|
374
422
|
publishPlanUsage,
|
|
423
|
+
publishUsageLimitReport,
|
|
375
424
|
publishFinalStream,
|
|
376
425
|
publishResponse,
|
|
377
426
|
publishPlanBoard,
|
|
@@ -142,13 +142,17 @@ export { shouldEmitStreamUpdate };
|
|
|
142
142
|
* - `background_tasks` → `{ tasks: [{ taskId, taskType, description }] }`
|
|
143
143
|
* (REPLACE semantics: the full live set after each membership change)
|
|
144
144
|
* - `background_task_settled` → `{ taskId, status }`
|
|
145
|
-
* - `result` → `{ text, isError, subtype, sessionId, model, usage }
|
|
145
|
+
* - `result` → `{ text, isError, subtype, sessionId, model, usage }`, and what
|
|
146
|
+
* names a failure: `{ assistantError, apiErrorStatus, terminalReason }`
|
|
146
147
|
*/
|
|
147
148
|
export function createSdkMessageNormalizer() {
|
|
148
149
|
const threads = new Map(); // threadKey -> per-thread stream/thinking bookkeeping
|
|
149
150
|
const knownSubagentRuns = new Map(); // toolUseId -> { displayName, parentSubagentId }
|
|
150
151
|
let initSessionId = '';
|
|
151
152
|
let initModel = '';
|
|
153
|
+
// The `error` of the turn's last main-thread assistant message: the SDK's
|
|
154
|
+
// name for the failure a refused turn ended on.
|
|
155
|
+
let lastAssistantError = '';
|
|
152
156
|
|
|
153
157
|
function threadKeyFor(parentToolUseId) {
|
|
154
158
|
return String(parentToolUseId || '').trim() || 'main';
|
|
@@ -532,6 +536,7 @@ export function createSdkMessageNormalizer() {
|
|
|
532
536
|
const parentToolUseId = sdkMessage?.parent_tool_use_id || null;
|
|
533
537
|
const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
534
538
|
const messageId = String(sdkMessage?.message?.id || '').trim();
|
|
539
|
+
if (!parentToolUseId) lastAssistantError = String(sdkMessage?.error || '').trim();
|
|
535
540
|
return actionsForAssistantMessage(content, parentToolUseId, messageId);
|
|
536
541
|
}
|
|
537
542
|
|
|
@@ -599,12 +604,19 @@ export function createSdkMessageNormalizer() {
|
|
|
599
604
|
&& Number(sdkMessage.duration_api_ms) === 0;
|
|
600
605
|
if (isPhantom) return [];
|
|
601
606
|
const isError = sdkMessage.subtype !== 'success' || sdkMessage.is_error === true;
|
|
607
|
+
// The next turn's result must not inherit this turn's failure.
|
|
608
|
+
const assistantError = lastAssistantError;
|
|
609
|
+
lastAssistantError = '';
|
|
610
|
+
const apiErrorStatus = Number(sdkMessage.api_error_status ?? NaN);
|
|
602
611
|
return [{
|
|
603
612
|
channel: 'result',
|
|
604
613
|
payload: {
|
|
605
614
|
text: String(sdkMessage.result || '').trim(),
|
|
606
615
|
isError,
|
|
607
616
|
subtype: String(sdkMessage.subtype || ''),
|
|
617
|
+
assistantError,
|
|
618
|
+
apiErrorStatus: Number.isFinite(apiErrorStatus) ? apiErrorStatus : null,
|
|
619
|
+
terminalReason: String(sdkMessage.terminal_reason || ''),
|
|
608
620
|
sessionId: String(sdkMessage.session_id || initSessionId || '').trim(),
|
|
609
621
|
model: initModel,
|
|
610
622
|
usage: sdkMessage.usage || null,
|
|
@@ -112,6 +112,22 @@ export function createModelSwitchUnconfirmedError({
|
|
|
112
112
|
return error;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
// JSON-RPC reserved codes the SDK's transport uses for a connection that went
|
|
116
|
+
// away (`MessageWriteError` … `ConnectionInactive`), as opposed to a server
|
|
117
|
+
// handler that threw.
|
|
118
|
+
const TRANSPORT_ERROR_CODES = new Set([-32099, -32098, -32097, -32096]);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The request failed because the connection to the runtime is gone, not
|
|
122
|
+
* because the runtime refused it.
|
|
123
|
+
*/
|
|
124
|
+
export function isRuntimeTransportError(error) {
|
|
125
|
+
const code = Number(error?.code);
|
|
126
|
+
if (Number.isFinite(code) && TRANSPORT_ERROR_CODES.has(code)) return true;
|
|
127
|
+
if (error?.name === 'ConnectionError') return true;
|
|
128
|
+
return /\bconnection (?:got |is |was )?(?:disposed|closed)\b/i.test(String(error?.message || ''));
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
export function isModelSwitchUnconfirmedError(error) {
|
|
116
132
|
return error?.modelSwitchUnconfirmed === true;
|
|
117
133
|
}
|
|
@@ -272,6 +288,16 @@ export function createCopilotModelSwitcher({
|
|
|
272
288
|
});
|
|
273
289
|
};
|
|
274
290
|
|
|
291
|
+
// A request that failed because the connection to the runtime is gone says
|
|
292
|
+
// nothing about the model: "pick an available model and resend it" sent
|
|
293
|
+
// the user to the model picker over a dead runtime, and because an
|
|
294
|
+
// unconfirmed switch keeps the runtime, every retry failed the same way.
|
|
295
|
+
// Thrown as it is, it fails the turn and the runtime is rebuilt.
|
|
296
|
+
const failOrRethrow = (error, requestedEffort = targetEffortRaw) => {
|
|
297
|
+
if (isRuntimeTransportError(error)) throw error;
|
|
298
|
+
return fail(error?.message || String(error), requestedEffort);
|
|
299
|
+
};
|
|
300
|
+
|
|
275
301
|
// ---- resolve the effort actually sent on the wire -----------------------
|
|
276
302
|
// `null` target = "the model's default": mapped to the catalog's
|
|
277
303
|
// `defaultReasoningEffort` when known, otherwise omitted entirely (and the
|
|
@@ -347,7 +373,7 @@ export function createCopilotModelSwitcher({
|
|
|
347
373
|
// the only signal left. Hosted trusts it; BYOK falls back to rebuild.
|
|
348
374
|
if (byok || typeof session?.setModel !== 'function') return fail('the runtime exposes no model-switch RPC');
|
|
349
375
|
await session.setModel(targetModel, effortToSend ? { reasoningEffort: effortToSend } : undefined)
|
|
350
|
-
.catch((error) =>
|
|
376
|
+
.catch((error) => failOrRethrow(error));
|
|
351
377
|
applied = { model: targetModel, effort: trackedEffort };
|
|
352
378
|
return { ok: true, changed: true };
|
|
353
379
|
}
|
|
@@ -363,7 +389,7 @@ export function createCopilotModelSwitcher({
|
|
|
363
389
|
});
|
|
364
390
|
} catch (error) {
|
|
365
391
|
drain.cancel();
|
|
366
|
-
return
|
|
392
|
+
return failOrRethrow(error);
|
|
367
393
|
}
|
|
368
394
|
if (result?.confirmation) {
|
|
369
395
|
// A compaction preflight wants an interactive decision; a headless
|
|
@@ -404,7 +430,7 @@ export function createCopilotModelSwitcher({
|
|
|
404
430
|
try {
|
|
405
431
|
effortResult = await setEffort.call(session.rpc.model, { reasoningEffort: effortToSend });
|
|
406
432
|
} catch (error) {
|
|
407
|
-
return
|
|
433
|
+
return failOrRethrow(error, effortToSend);
|
|
408
434
|
}
|
|
409
435
|
const recorded = String(effortResult?.reasoningEffort || '').trim().toLowerCase();
|
|
410
436
|
if (recorded && recorded !== effortToSend) {
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// question array. Composing the waiter instead of copying it keeps one
|
|
24
24
|
// implementation of the abort semantics.
|
|
25
25
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
26
|
+
import { createRelayQuestion } from '../../shared/question-wait.mjs';
|
|
26
27
|
import { looksLikeMultiSelectQuestion } from '../../shared/question-multi-select.mjs';
|
|
27
28
|
import {
|
|
28
29
|
DEFAULT_QUESTION_TIMEOUT_MS,
|
|
@@ -139,10 +140,7 @@ export function createCopilotQuestionBridge({
|
|
|
139
140
|
...extra,
|
|
140
141
|
},
|
|
141
142
|
};
|
|
142
|
-
|
|
143
|
-
const questionId = created?.question?.id;
|
|
144
|
-
if (!questionId) throw new Error('Relay question could not be created');
|
|
145
|
-
return questionId;
|
|
143
|
+
return createRelayQuestion({ api, payload, dbg });
|
|
146
144
|
}
|
|
147
145
|
|
|
148
146
|
async function ask(spec, { signal, message } = {}) {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The commands of this worker's Copilot runtime, and when they are stopped.
|
|
2
|
+
//
|
|
3
|
+
// The runtime ends its own commands when it ends in good order. When it is
|
|
4
|
+
// killed, or dies, they run on with nobody left to read their result (the
|
|
5
|
+
// measurements are in shared/worker-runtime/process-tree.mjs). The worker
|
|
6
|
+
// therefore keeps the runtime's process tree while the runtime lives and
|
|
7
|
+
// stops what is left of it after a runtime that failed is gone.
|
|
8
|
+
import { createProcessTreeWatch } from '../../shared/worker-runtime/process-tree.mjs';
|
|
9
|
+
import { createProcessLister } from '../services/process-list-service.mjs';
|
|
10
|
+
import { readRuntimePid } from './copilot-sdk-adapter.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The reasons of `stopRuntime` after which the tree is stopped: the runtime
|
|
14
|
+
* exited, it no longer answers, or it is torn down because its turn failed.
|
|
15
|
+
* An idle runtime and a worker that shuts down stop in good order, and the
|
|
16
|
+
* runtime ends its commands itself.
|
|
17
|
+
*/
|
|
18
|
+
const TREE_STOP_REASONS = new Set(['runtime-exit', 'runtime-gone', 'turn-failure']);
|
|
19
|
+
|
|
20
|
+
export function runtimeStopLeavesCommands(reason) {
|
|
21
|
+
return TREE_STOP_REASONS.has(String(reason || ''));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The wrapper the runtime puts around a command that was started with
|
|
26
|
+
* `detach: true`. Such a command is meant to outlive the session ("only set
|
|
27
|
+
* this when the user explicitly requires the process to survive"), and the
|
|
28
|
+
* runtime leaves it running when it stops in good order. So does the worker.
|
|
29
|
+
*
|
|
30
|
+
* On Linux and macOS (runtime 1.0.89) the wrapper is a shell that keeps the
|
|
31
|
+
* pid and exit paths in variables. On Windows (1.0.88, seen live) it is a
|
|
32
|
+
* PowerShell that starts the command with Start-Process; the command itself
|
|
33
|
+
* is a child of the wrapper and is kept with it. The wrapper's text is the
|
|
34
|
+
* mark: the names of its files (`copilot-detached-…`) would also match a
|
|
35
|
+
* command that merely mentions one.
|
|
36
|
+
*/
|
|
37
|
+
const DETACHED_COMMAND_MARKERS = Object.freeze([
|
|
38
|
+
'__copilot_pid_path=',
|
|
39
|
+
'$__copilotProcess = Start-Process',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export function isDetachedRuntimeCommand(proc) {
|
|
43
|
+
const commandLine = String(proc?.commandLine || '');
|
|
44
|
+
return DETACHED_COMMAND_MARKERS.some((marker) => commandLine.includes(marker));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The sentence of the failure note. "is being stopped", not "was": the note
|
|
49
|
+
* is published before the runtime is torn down, and the commands are stopped
|
|
50
|
+
* after it.
|
|
51
|
+
*/
|
|
52
|
+
export function describeCommandsBeingStopped(count) {
|
|
53
|
+
const commands = Number(count) || 0;
|
|
54
|
+
if (commands <= 0) return '';
|
|
55
|
+
if (commands === 1) return 'A command that was still running is being stopped.';
|
|
56
|
+
return `${commands} commands that were still running are being stopped.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Watch the process tree of `client`'s runtime. Returns null where the pid of
|
|
61
|
+
* the runtime is not known: nothing is signalled on a guess.
|
|
62
|
+
*/
|
|
63
|
+
export function watchCopilotRuntimeTree({
|
|
64
|
+
client,
|
|
65
|
+
isBusy = () => true,
|
|
66
|
+
dbg = () => {},
|
|
67
|
+
platform = process.platform,
|
|
68
|
+
lister = null,
|
|
69
|
+
signal = (pid, name) => process.kill(pid, name),
|
|
70
|
+
// Never signalled, whatever the process list says.
|
|
71
|
+
selfPid = process.pid,
|
|
72
|
+
parentPid = process.ppid,
|
|
73
|
+
...timing
|
|
74
|
+
} = {}) {
|
|
75
|
+
const rootPid = readRuntimePid(client);
|
|
76
|
+
if (!rootPid) {
|
|
77
|
+
dbg('the pid of the copilot runtime is not known; its commands are not watched');
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return createProcessTreeWatch({
|
|
81
|
+
rootPid,
|
|
82
|
+
lister: lister || createProcessLister({ platform }),
|
|
83
|
+
signal,
|
|
84
|
+
protectedPids: [selfPid, parentPid],
|
|
85
|
+
keepSubtree: isDetachedRuntimeCommand,
|
|
86
|
+
isBusy,
|
|
87
|
+
label: 'copilot runtime',
|
|
88
|
+
dbg,
|
|
89
|
+
...timing,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
MODEL_SWITCH_UNCONFIRMED_CODE,
|
|
29
29
|
MODEL_SWITCH_UNCONFIRMED_STABLE_CODE,
|
|
30
30
|
isModelSwitchUnconfirmedError,
|
|
31
|
+
isRuntimeTransportError,
|
|
31
32
|
} from './copilot-model-switch.mjs';
|
|
32
33
|
import { copilotRuntimeEntry } from '../copilot-sdk-runtime.mjs';
|
|
34
|
+
import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
33
35
|
|
|
34
36
|
const VERSION_DIR_RE = /^\d+\.\d+\.\d+$/;
|
|
35
37
|
|
|
@@ -162,6 +164,22 @@ export function observeRuntimeExit(client, onExit) {
|
|
|
162
164
|
return () => { live = false; };
|
|
163
165
|
}
|
|
164
166
|
|
|
167
|
+
/**
|
|
168
|
+
* The pid of the runtime process, or 0.
|
|
169
|
+
*
|
|
170
|
+
* Like `processExitPromise` this reaches past the public API: the SDK starts
|
|
171
|
+
* the runtime itself (`spawn` without `detached`, no option for a spawn
|
|
172
|
+
* function of our own, verified against 1.0.89 and the npm package 1.0.14) and
|
|
173
|
+
* keeps the child in `cliProcess`, a TS-private, runtime-visible field that
|
|
174
|
+
* `stop()` and `forceStop()` clear. It is read once, right after the start. A
|
|
175
|
+
* bundle without the field reads as "not known", and the runtime's commands
|
|
176
|
+
* are then not watched.
|
|
177
|
+
*/
|
|
178
|
+
export function readRuntimePid(client) {
|
|
179
|
+
const pid = Number(client?.cliProcess?.pid);
|
|
180
|
+
return Number.isInteger(pid) && pid > 1 ? pid : 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
/**
|
|
166
184
|
* Import the installed SDK and start a client against the matching runtime.
|
|
167
185
|
*
|
|
@@ -402,6 +420,8 @@ export function classifyCopilotSessionError(data) {
|
|
|
402
420
|
code,
|
|
403
421
|
stableCode: `copilot.${code}`,
|
|
404
422
|
text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
|
|
423
|
+
// Without one the relay adds its default, which advises a retry.
|
|
424
|
+
guidance: COPILOT_TURN_FAILURE_GUIDANCE,
|
|
405
425
|
detail,
|
|
406
426
|
quota: false,
|
|
407
427
|
};
|
|
@@ -469,6 +489,20 @@ export function isCopilotAuthError(error) {
|
|
|
469
489
|
return AUTH_ERROR_RE.test(String(error?.message || error || ''));
|
|
470
490
|
}
|
|
471
491
|
|
|
492
|
+
/**
|
|
493
|
+
* What the relay appends to a failed Copilot turn in place of its own default
|
|
494
|
+
* (which advises a retry). The worker deals with its runtime itself: a delivered
|
|
495
|
+
* turn that failed tore it down and the next message starts a fresh one, so a
|
|
496
|
+
* relay restart repairs nothing here and costs every other running session.
|
|
497
|
+
* That holds for a failure the runtime reports as a session error too.
|
|
498
|
+
* Sign-in and quota failures keep the wording of their own.
|
|
499
|
+
*/
|
|
500
|
+
export const COPILOT_TURN_FAILURE_GUIDANCE =
|
|
501
|
+
'If this keeps failing, include the error code when you report it; the relay does not need a restart.';
|
|
502
|
+
export const COPILOT_TURN_RETRY_GUIDANCE = 'Send the message again to retry.';
|
|
503
|
+
export const COPILOT_TURN_CONTINUE_GUIDANCE =
|
|
504
|
+
'Send a message to continue: the session keeps the work so far, so the agent carries on from there.';
|
|
505
|
+
|
|
472
506
|
/**
|
|
473
507
|
* Classify a thrown error (client start, session create/resume, `send`) rather
|
|
474
508
|
* than a `session.error` event. Auth gets its own branch because the fix is a
|
|
@@ -503,19 +537,27 @@ export function classifyCopilotTurnException(error) {
|
|
|
503
537
|
if (isCopilotAuthError(error)) {
|
|
504
538
|
return copilotAuthClassification(detail);
|
|
505
539
|
}
|
|
540
|
+
// The stall watchdog's failure: the turn is not known to be broken, only
|
|
541
|
+
// silent, and the user is told that — and what the runtime was doing —
|
|
542
|
+
// rather than "the turn failed".
|
|
543
|
+
if (isTurnStalledError(error)) {
|
|
544
|
+
return {
|
|
545
|
+
code: 'turn-stalled',
|
|
546
|
+
stableCode: 'copilot.turn-stalled',
|
|
547
|
+
text: describeTurnStall(error),
|
|
548
|
+
guidance: COPILOT_TURN_RETRY_GUIDANCE,
|
|
549
|
+
detail,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
506
552
|
return {
|
|
507
553
|
code: 'turn-error',
|
|
508
554
|
stableCode: 'copilot.turn-error',
|
|
509
555
|
text: `System note: the Copilot turn failed (${detail}). Retry or send a new message.`,
|
|
556
|
+
guidance: COPILOT_TURN_FAILURE_GUIDANCE,
|
|
510
557
|
detail,
|
|
511
558
|
};
|
|
512
559
|
}
|
|
513
560
|
|
|
514
|
-
// JSON-RPC reserved codes the SDK's transport uses for a connection that went
|
|
515
|
-
// away (`MessageWriteError` … `ConnectionInactive`), as opposed to a server
|
|
516
|
-
// handler that threw.
|
|
517
|
-
const TRANSPORT_ERROR_CODES = new Set([-32099, -32098, -32097, -32096]);
|
|
518
|
-
|
|
519
561
|
/**
|
|
520
562
|
* Did `resumeSession` fail because the session does not exist, or because the
|
|
521
563
|
* call itself failed?
|
|
@@ -530,9 +572,7 @@ const TRANSPORT_ERROR_CODES = new Set([-32099, -32098, -32097, -32096]);
|
|
|
530
572
|
* conversation history.
|
|
531
573
|
*/
|
|
532
574
|
export function isSessionNotFoundError(error) {
|
|
533
|
-
|
|
534
|
-
if (Number.isFinite(code) && TRANSPORT_ERROR_CODES.has(code)) return false;
|
|
535
|
-
if (error?.name === 'ConnectionError') return false;
|
|
575
|
+
if (isRuntimeTransportError(error)) return false;
|
|
536
576
|
const detail = String(error?.message || error || '').toLowerCase();
|
|
537
577
|
if (!detail) return false;
|
|
538
578
|
if (/\b(?:session|conversation)\b[^.]{0,40}\bnot found\b/.test(detail)) return true;
|