@oar-sh/oar 0.9.5 → 0.9.7
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 +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -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/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -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-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- 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 +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- 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 +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- 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/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -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
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
createTurnLiveness,
|
|
6
|
+
createTurnStalledError,
|
|
7
|
+
describeTurnStall,
|
|
8
|
+
isTurnStalledError,
|
|
9
|
+
resolveTurnStallWindows,
|
|
10
|
+
} from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
11
|
+
|
|
4
12
|
/**
|
|
5
13
|
* The only module in the repo that imports `@cursor/sdk` (and its
|
|
6
14
|
* `@cursor/sdk/sqlite` entry). All SDK access is injectable and the real
|
|
@@ -169,10 +177,16 @@ export function startCursorRun({
|
|
|
169
177
|
// LocalSendOptions passthrough — `{ force: true }` is the SDK's documented
|
|
170
178
|
// recovery for a local agent left wedged by a crashed process.
|
|
171
179
|
sendLocal = null,
|
|
172
|
-
// Turn backstop: with no SDK traffic for
|
|
180
|
+
// Turn backstop: with no SDK traffic for too long (and no client-hosted
|
|
173
181
|
// work pending, e.g. an open ask_user card) the run is failed as stalled
|
|
174
|
-
// instead of parking the merged iterator forever.
|
|
175
|
-
|
|
182
|
+
// instead of parking the merged iterator forever. How long depends on what
|
|
183
|
+
// the run is doing (shared/worker-runtime/turn-liveness.mjs):
|
|
184
|
+
// `stallTimeoutMs` until the run exists, the model window while it works,
|
|
185
|
+
// the tool window while a tool call is open. 0 disables.
|
|
186
|
+
stallTimeoutMs = undefined,
|
|
187
|
+
stallModelTimeoutMs = undefined,
|
|
188
|
+
stallToolTimeoutMs = undefined,
|
|
189
|
+
env = process.env,
|
|
176
190
|
hasPendingClientWork = () => false,
|
|
177
191
|
dbg = () => {},
|
|
178
192
|
} = {}) {
|
|
@@ -181,8 +195,11 @@ export function startCursorRun({
|
|
|
181
195
|
let failure = null;
|
|
182
196
|
let notify = null;
|
|
183
197
|
let run = null;
|
|
184
|
-
let lastEventAt = Date.now();
|
|
185
198
|
let stallTimer = null;
|
|
199
|
+
const stallWindows = resolveTurnStallWindows({
|
|
200
|
+
env, idleMs: stallTimeoutMs, modelMs: stallModelTimeoutMs, toolMs: stallToolTimeoutMs,
|
|
201
|
+
});
|
|
202
|
+
const liveness = createTurnLiveness({ windows: stallWindows });
|
|
186
203
|
|
|
187
204
|
const wake = () => {
|
|
188
205
|
if (!notify) return;
|
|
@@ -190,9 +207,25 @@ export function startCursorRun({
|
|
|
190
207
|
notify = null;
|
|
191
208
|
resolve();
|
|
192
209
|
};
|
|
210
|
+
// A tool call is reported on both surfaces (the delta hook and the message
|
|
211
|
+
// stream) under the same id; either one opens and closes it.
|
|
212
|
+
const observe = (event) => {
|
|
213
|
+
if (event.source === 'delta') {
|
|
214
|
+
const update = event.update || {};
|
|
215
|
+
liveness.traffic(update.type);
|
|
216
|
+
if (update.type === 'tool-call-started') liveness.begin('tool', update.callId, update.toolCall?.type);
|
|
217
|
+
else if (update.type === 'tool-call-completed') liveness.end('tool', update.callId);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const message = event.message || {};
|
|
221
|
+
liveness.traffic(message.type);
|
|
222
|
+
if (message.type !== 'tool_call') return;
|
|
223
|
+
if (message.status === 'running') liveness.begin('tool', message.call_id, message.name);
|
|
224
|
+
else if (message.status) liveness.end('tool', message.call_id);
|
|
225
|
+
};
|
|
193
226
|
const push = (event) => {
|
|
194
227
|
if (ended) return;
|
|
195
|
-
|
|
228
|
+
observe(event);
|
|
196
229
|
queue.push(event);
|
|
197
230
|
wake();
|
|
198
231
|
};
|
|
@@ -216,21 +249,31 @@ export function startCursorRun({
|
|
|
216
249
|
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
217
250
|
}
|
|
218
251
|
|
|
219
|
-
if (!ended &&
|
|
252
|
+
if (!ended && stallWindows.idle > 0) {
|
|
220
253
|
stallTimer = setInterval(() => {
|
|
221
254
|
if (ended) return;
|
|
222
255
|
if (hasPendingClientWork()) {
|
|
223
256
|
// A human is thinking about a question card; the clock restarts when
|
|
224
257
|
// the tool settles and traffic resumes.
|
|
225
|
-
|
|
258
|
+
liveness.traffic('client-work');
|
|
226
259
|
return;
|
|
227
260
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
261
|
+
const verdict = liveness.check();
|
|
262
|
+
if (!verdict.stalled) return;
|
|
263
|
+
failure = failure || createTurnStalledError({
|
|
264
|
+
agentLabel: 'Cursor',
|
|
265
|
+
quietMs: verdict.quietMs,
|
|
266
|
+
phase: verdict.phase,
|
|
267
|
+
tools: liveness.toolsInFlight(),
|
|
268
|
+
});
|
|
269
|
+
dbg('cursor run stalled, ending merged stream', liveness.describe());
|
|
270
|
+
// The run is still the agent's active one; left alone it made the next
|
|
271
|
+
// message wait for the busy retry.
|
|
272
|
+
Promise.resolve().then(() => run?.cancel?.()).catch((error) => {
|
|
273
|
+
dbg('cursor run cancel after a stall failed', error?.message || String(error));
|
|
274
|
+
});
|
|
275
|
+
end();
|
|
276
|
+
}, Math.min(15_000, Math.max(5, Math.floor(stallWindows.idle / 4))));
|
|
234
277
|
stallTimer.unref?.();
|
|
235
278
|
}
|
|
236
279
|
|
|
@@ -247,6 +290,17 @@ export function startCursorRun({
|
|
|
247
290
|
...(sendLocal && typeof sendLocal === 'object' ? { local: sendLocal } : {}),
|
|
248
291
|
onDelta: ({ update }) => push({ source: 'delta', update }),
|
|
249
292
|
});
|
|
293
|
+
if (ended) {
|
|
294
|
+
// Stalled (or aborted) before the run existed: nothing cancelled it
|
|
295
|
+
// then, and left active it makes the next message wait for it.
|
|
296
|
+
Promise.resolve().then(() => run?.cancel?.()).catch((error) => {
|
|
297
|
+
dbg('cursor run cancel after a late start failed', error?.message || String(error));
|
|
298
|
+
});
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// From here on the run is either waiting for the model or for a tool.
|
|
302
|
+
liveness.traffic('run-started');
|
|
303
|
+
liveness.begin('model', 'run');
|
|
250
304
|
for await (const sdkMessage of run.stream()) {
|
|
251
305
|
if (ended) break;
|
|
252
306
|
push({ source: 'stream', message: sdkMessage });
|
|
@@ -314,6 +368,19 @@ export function classifyCursorError(error) {
|
|
|
314
368
|
const message = String(error?.message || error);
|
|
315
369
|
const isRetryable = error?.isRetryable === true;
|
|
316
370
|
const name = String(error?.name || '');
|
|
371
|
+
if (isTurnStalledError(error)) {
|
|
372
|
+
return {
|
|
373
|
+
isAuth: false,
|
|
374
|
+
isBusy: false,
|
|
375
|
+
isStalled: true,
|
|
376
|
+
isRetryable,
|
|
377
|
+
code: 'turn-stalled',
|
|
378
|
+
stableCode: 'cursor.turn-stalled',
|
|
379
|
+
message,
|
|
380
|
+
// What the user reads; `message` is the log's wording.
|
|
381
|
+
text: describeTurnStall(error),
|
|
382
|
+
};
|
|
383
|
+
}
|
|
317
384
|
if (name === 'AuthenticationError' || isCursorAuthErrorMessage(message)) {
|
|
318
385
|
return {
|
|
319
386
|
isAuth: true,
|
|
@@ -778,13 +778,16 @@ export function createCursorTurnRunner({
|
|
|
778
778
|
await publishResponse(message, {
|
|
779
779
|
text: classified.isAuth
|
|
780
780
|
? `System note: the Cursor runtime could not authenticate (${classified.message}). Set or renew the Cursor API key in provider settings, then retry.`
|
|
781
|
-
: `System note: the Cursor turn failed (${classified.message}).`,
|
|
781
|
+
: classified.text || `System note: the Cursor turn failed (${classified.message}).`,
|
|
782
782
|
model: null,
|
|
783
783
|
terminalError: {
|
|
784
784
|
kind: 'cursor-turn-failed',
|
|
785
785
|
code: classified.code,
|
|
786
786
|
stableCode: classified.stableCode,
|
|
787
|
-
|
|
787
|
+
// A stall is told in the user's terms, and with its own advice: the
|
|
788
|
+
// run was cancelled, so sending the message again is all it takes.
|
|
789
|
+
message: classified.text || classified.message,
|
|
790
|
+
...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
|
|
788
791
|
failedAt: new Date().toISOString(),
|
|
789
792
|
queueMessageId: String(message.id || '') || null,
|
|
790
793
|
},
|
package/server/db-schema.mjs
CHANGED
|
@@ -11,6 +11,8 @@ import { rebuildRecentWorkspaceRootsTable } from './migrations/0002-recent-works
|
|
|
11
11
|
import { ensurePushSubscriptionsTable } from './migrations/0003-push-subscriptions.mjs';
|
|
12
12
|
import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-markers.mjs';
|
|
13
13
|
import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
|
|
14
|
+
import { migrateWorkspaceRootSettings } from './migrations/0006-workspace-root-settings.mjs';
|
|
15
|
+
import { migrateClaudeCloud } from './migrations/0007-claude-cloud.mjs';
|
|
14
16
|
import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
|
|
15
17
|
|
|
16
18
|
// Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
|
|
@@ -665,6 +667,13 @@ if (!queueColumns.includes('continuation_op_id')) {
|
|
|
665
667
|
// after a lost response must find the row it already created, not mint a twin.
|
|
666
668
|
db.exec(`ALTER TABLE queue ADD COLUMN continuation_op_id TEXT`);
|
|
667
669
|
}
|
|
670
|
+
if (!queueColumns.includes('usage_limit_pause')) {
|
|
671
|
+
// Set on the follow-up the relay queues when a Claude turn is refused at the
|
|
672
|
+
// subscription's usage limit (JSON: which window, its reset, when to send).
|
|
673
|
+
// While such a row is pending with next_attempt_at ahead, the turn is
|
|
674
|
+
// paused: it is held back, keeps no worker warm and blocks no restart.
|
|
675
|
+
db.exec(`ALTER TABLE queue ADD COLUMN usage_limit_pause TEXT`);
|
|
676
|
+
}
|
|
668
677
|
|
|
669
678
|
// recent_workspace_roots gained a case-normalized primary key (path_key). The
|
|
670
679
|
// CREATE TABLE IF NOT EXISTS above only covers fresh databases, so upgrade an
|
|
@@ -710,6 +719,21 @@ try {
|
|
|
710
719
|
} catch (error) {
|
|
711
720
|
console.warn(`[remote-relays] migration 0005 failed; retrying on next boot: ${error?.message || error}`);
|
|
712
721
|
}
|
|
722
|
+
// Per-folder settings (commit attribution override). Readers tolerate the
|
|
723
|
+
// table's absence, so a failure is logged and retried next boot like 0005.
|
|
724
|
+
try {
|
|
725
|
+
migrateWorkspaceRootSettings(db);
|
|
726
|
+
} catch (error) {
|
|
727
|
+
console.warn(`[workspace-root-settings] migration 0006 failed; retrying on next boot: ${error?.message || error}`);
|
|
728
|
+
}
|
|
729
|
+
// Claude Cloud conversations: the cloud session binding and what it clones.
|
|
730
|
+
// Additive columns whose readers tolerate their absence, so a failure is
|
|
731
|
+
// logged and retried next boot like 0006.
|
|
732
|
+
try {
|
|
733
|
+
migrateClaudeCloud(db);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
console.warn(`[claude-cloud] migration 0007 failed; retrying on next boot: ${error?.message || error}`);
|
|
736
|
+
}
|
|
713
737
|
|
|
714
738
|
const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
|
|
715
739
|
if (runtimeSessionColumns.length) {
|
|
@@ -7,14 +7,24 @@ import { EventEmitter } from 'node:events';
|
|
|
7
7
|
import { spawn } from 'node:child_process';
|
|
8
8
|
import readline from 'node:readline';
|
|
9
9
|
|
|
10
|
+
import {
|
|
11
|
+
createTurnLiveness,
|
|
12
|
+
createTurnStalledError,
|
|
13
|
+
resolveTurnStallWindows,
|
|
14
|
+
} from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
15
|
+
|
|
10
16
|
function whichCommand(command = 'grok') {
|
|
11
17
|
const value = String(command || 'grok').trim() || 'grok';
|
|
12
18
|
return value;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
|
-
// Prompt watchdog defaults: a turn that produces no ACP traffic for
|
|
16
|
-
//
|
|
17
|
-
//
|
|
21
|
+
// Prompt watchdog defaults: a turn that produces no ACP traffic for too long
|
|
22
|
+
// (while no client-side work is pending) is stalled; the ceiling bounds a
|
|
23
|
+
// single prompt absolutely. Both are overridable per call. How long "too
|
|
24
|
+
// long" is depends on what the agent is doing
|
|
25
|
+
// (shared/worker-runtime/turn-liveness.mjs): a prompt in flight is a model
|
|
26
|
+
// request unless one of the agent's own tool calls is open, and the
|
|
27
|
+
// inactivity window below is the base the two are derived from.
|
|
18
28
|
export const ACP_PROMPT_INACTIVITY_MS = 120_000;
|
|
19
29
|
export const ACP_PROMPT_MAX_TURN_MS = 1_800_000;
|
|
20
30
|
// How long the watchdog waits for its `holdStall` question before treating
|
|
@@ -352,12 +362,31 @@ export class AcpClient extends EventEmitter {
|
|
|
352
362
|
* `holdCheckTimeoutMs` counts as `false`.
|
|
353
363
|
*
|
|
354
364
|
* @param {object} [extra] additional session/prompt params (e.g. `_meta`)
|
|
355
|
-
* @param {object} [watchdog] { inactivityMs, maxTurnMs, hasPendingWork, holdStall, holdCheckTimeoutMs }
|
|
365
|
+
* @param {object} [watchdog] { inactivityMs, modelMs, toolMs, env, maxTurnMs, hasPendingWork, holdStall, holdCheckTimeoutMs }
|
|
356
366
|
*/
|
|
357
367
|
async sessionPrompt(sessionId, prompt, onUpdate, extra = {}, watchdog = {}) {
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
:
|
|
368
|
+
const resolved = resolveTurnStallWindows({
|
|
369
|
+
env: watchdog?.env ?? process.env,
|
|
370
|
+
idleMs: Number(watchdog?.inactivityMs) > 0 ? Number(watchdog.inactivityMs) : undefined,
|
|
371
|
+
modelMs: watchdog?.modelMs,
|
|
372
|
+
toolMs: watchdog?.toolMs,
|
|
373
|
+
});
|
|
374
|
+
// An environment that turns the shared watchdog off does not turn this
|
|
375
|
+
// one off: it is the only thing that notices a dead transport. An idle
|
|
376
|
+
// window of 0 takes the two others to 0 with it, so all three are
|
|
377
|
+
// rebuilt from the default. A window the caller named stays the
|
|
378
|
+
// caller's.
|
|
379
|
+
const stallWindows = resolved.idle > 0
|
|
380
|
+
? resolved
|
|
381
|
+
: resolveTurnStallWindows({
|
|
382
|
+
env: {},
|
|
383
|
+
idleMs: ACP_PROMPT_INACTIVITY_MS,
|
|
384
|
+
modelMs: watchdog?.modelMs,
|
|
385
|
+
toolMs: watchdog?.toolMs,
|
|
386
|
+
});
|
|
387
|
+
const inactivityMs = stallWindows.idle;
|
|
388
|
+
const liveness = createTurnLiveness({ windows: stallWindows });
|
|
389
|
+
liveness.begin('model', 'prompt');
|
|
361
390
|
// An explicit 0 means NO absolute ceiling (the user's "No limit"
|
|
362
391
|
// setting); only an absent/invalid value falls back to the default. The
|
|
363
392
|
// inactivity watchdog still catches dead transports either way.
|
|
@@ -375,7 +404,22 @@ export class AcpClient extends EventEmitter {
|
|
|
375
404
|
const handler = (msg) => {
|
|
376
405
|
if (msg.method !== 'session/update') return;
|
|
377
406
|
if (msg.params?.sessionId && msg.params.sessionId !== sessionId) return;
|
|
378
|
-
|
|
407
|
+
const update = msg.params?.update || msg.params;
|
|
408
|
+
noteToolCall(update);
|
|
409
|
+
onUpdate?.(update);
|
|
410
|
+
};
|
|
411
|
+
// The agent's own tool calls (the ones it runs itself; client-hosted
|
|
412
|
+
// terminals are `hasPendingWork`). One that prints nothing sends nothing.
|
|
413
|
+
const noteToolCall = (update) => {
|
|
414
|
+
const kind = String(update?.sessionUpdate || update?.type || '').trim();
|
|
415
|
+
if (kind !== 'tool_call' && kind !== 'tool_call_update') return;
|
|
416
|
+
const id = String(update.toolCallId || update.tool_call_id || update.id || '').trim();
|
|
417
|
+
const status = String(update.status || (kind === 'tool_call' ? 'pending' : '')).trim().toLowerCase();
|
|
418
|
+
if (status === 'pending' || status === 'in_progress' || status === 'running') {
|
|
419
|
+
liveness.begin('tool', id, String(update.title || update.name || update.kind || '').trim());
|
|
420
|
+
} else if (status) {
|
|
421
|
+
liveness.end('tool', id);
|
|
422
|
+
}
|
|
379
423
|
};
|
|
380
424
|
this.on('notification', handler);
|
|
381
425
|
let watchdogTimer = null;
|
|
@@ -386,10 +430,17 @@ export class AcpClient extends EventEmitter {
|
|
|
386
430
|
const quietSince = () => Math.max(this.lastActivityAt, heldAt);
|
|
387
431
|
const startedAt = Date.now();
|
|
388
432
|
const stallPromise = new Promise((_, reject) => {
|
|
433
|
+
const quiet = () => liveness.check({ quietMs: Date.now() - quietSince() });
|
|
389
434
|
const stall = () => {
|
|
390
435
|
if (settled) return;
|
|
391
436
|
try { this.sessionCancel(sessionId); } catch { /* ignore */ }
|
|
392
|
-
|
|
437
|
+
const verdict = quiet();
|
|
438
|
+
reject(createTurnStalledError({
|
|
439
|
+
agentLabel: 'Grok',
|
|
440
|
+
quietMs: verdict.quietMs,
|
|
441
|
+
phase: verdict.phase,
|
|
442
|
+
tools: liveness.toolsInFlight(),
|
|
443
|
+
}));
|
|
393
444
|
};
|
|
394
445
|
const askForHold = () => {
|
|
395
446
|
let timer = null;
|
|
@@ -410,10 +461,15 @@ export class AcpClient extends EventEmitter {
|
|
|
410
461
|
return;
|
|
411
462
|
}
|
|
412
463
|
// Traffic that arrived while the question ran re-armed it anyway.
|
|
413
|
-
if (
|
|
464
|
+
if (quiet().stalled) stall();
|
|
414
465
|
});
|
|
415
466
|
};
|
|
416
|
-
|
|
467
|
+
// Often enough for the shortest window that is set, not only for the
|
|
468
|
+
// idle one.
|
|
469
|
+
const shortestWindowMs = Math.min(
|
|
470
|
+
...[stallWindows.idle, stallWindows.model, stallWindows.tool].filter((ms) => Number(ms) > 0),
|
|
471
|
+
);
|
|
472
|
+
const pollMs = Math.max(250, Math.min(5000, Math.floor(shortestWindowMs / 4)));
|
|
417
473
|
watchdogTimer = setInterval(() => {
|
|
418
474
|
const now = Date.now();
|
|
419
475
|
if (maxTurnMs > 0 && now - startedAt >= maxTurnMs) {
|
|
@@ -422,7 +478,7 @@ export class AcpClient extends EventEmitter {
|
|
|
422
478
|
return;
|
|
423
479
|
}
|
|
424
480
|
if (hasPendingWork()) return;
|
|
425
|
-
if (
|
|
481
|
+
if (!quiet().stalled) return;
|
|
426
482
|
if (!holdStall) {
|
|
427
483
|
stall();
|
|
428
484
|
return;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { AcpClient, extractGrokModelsFromInitialize, pickAllowOnceOptionId } from './acp-client.mjs';
|
|
6
6
|
import { createAcpHostServices } from './acp-host-services.mjs';
|
|
7
7
|
import { createSdkMessageNormalizer } from './sdk-message-normalizer.mjs';
|
|
8
|
+
import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
8
9
|
|
|
9
10
|
export { extractGrokModelsFromInitialize };
|
|
10
11
|
|
|
@@ -44,6 +45,9 @@ export function classifyGrokError(error) {
|
|
|
44
45
|
return {
|
|
45
46
|
code: 'grok.turn-stalled',
|
|
46
47
|
message: message || 'Grok turn stalled',
|
|
48
|
+
// The inactivity trip in the user's terms: how long, and what the agent
|
|
49
|
+
// was doing. The ceiling's own message already is.
|
|
50
|
+
...(isTurnStalledError(error) ? { text: describeTurnStall(error) } : {}),
|
|
47
51
|
isAuth: false,
|
|
48
52
|
isBusy: false,
|
|
49
53
|
isStalled: true,
|
|
@@ -63,7 +63,10 @@ function buildGrokTerminalError(classified, message) {
|
|
|
63
63
|
kind: 'grok-turn-failed',
|
|
64
64
|
code: classified.code,
|
|
65
65
|
stableCode: classified.code,
|
|
66
|
-
message: classified.message,
|
|
66
|
+
message: classified.text || classified.message,
|
|
67
|
+
// A stalled turn was cancelled and its agent is restarted for the next
|
|
68
|
+
// message, so sending the message again is all it takes.
|
|
69
|
+
...(classified.isStalled ? { guidance: 'Send the message again to retry.' } : {}),
|
|
67
70
|
failedAt: new Date().toISOString(),
|
|
68
71
|
queueMessageId: String(message?.id || '') || null,
|
|
69
72
|
};
|
|
@@ -584,7 +587,8 @@ export function createGrokTurnRunner({
|
|
|
584
587
|
const noteText = classified.isAuth
|
|
585
588
|
? `System note: the Grok agent could not authenticate (${classified.message})`
|
|
586
589
|
: classified.isStalled
|
|
587
|
-
?
|
|
590
|
+
? classified.text
|
|
591
|
+
|| `System note: the Grok turn stalled and was stopped (${classified.message}). Send a new message to retry.`
|
|
588
592
|
: `System note: the Grok turn failed (${classified.message}).`;
|
|
589
593
|
await publishResponse(message, {
|
|
590
594
|
text: noteText,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: per-folder settings (`workspace_root_settings`).
|
|
5
|
+
*
|
|
6
|
+
* The first setting a repo folder can carry is the commit attribution mode
|
|
7
|
+
* of its Claude sessions (shared/claude-attribution.mjs): a repo that forbids
|
|
8
|
+
* co-author trailers, or wants its own, overrides the provider setting for
|
|
9
|
+
* every session that runs in it. Keyed like `recent_workspace_roots`
|
|
10
|
+
* (`normalizeWorkspaceRootKey`), but its own table: the MRU list is pruned.
|
|
11
|
+
*
|
|
12
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
13
|
+
*
|
|
14
|
+
* Usage: node server/migrations/0006-workspace-root-settings.mjs [path/to/copilot.db]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import process from 'process';
|
|
20
|
+
import Database from 'better-sqlite3';
|
|
21
|
+
|
|
22
|
+
function tableExists(db, name) {
|
|
23
|
+
return !!db
|
|
24
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
25
|
+
.get(name);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function migrateWorkspaceRootSettings(db) {
|
|
29
|
+
const existed = tableExists(db, 'workspace_root_settings');
|
|
30
|
+
db.exec(`
|
|
31
|
+
CREATE TABLE IF NOT EXISTS workspace_root_settings (
|
|
32
|
+
path_key TEXT PRIMARY KEY,
|
|
33
|
+
path TEXT NOT NULL,
|
|
34
|
+
attribution_mode TEXT,
|
|
35
|
+
updated_at TEXT NOT NULL
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
return { applied: !existed, tableCreated: !existed };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defaultDbPath() {
|
|
42
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function migrate(dbPath, options = {}) {
|
|
47
|
+
const db = new Database(dbPath, options);
|
|
48
|
+
try {
|
|
49
|
+
return migrateWorkspaceRootSettings(db);
|
|
50
|
+
} finally {
|
|
51
|
+
db.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
56
|
+
if (isMain) {
|
|
57
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
58
|
+
try {
|
|
59
|
+
const result = migrate(dbPath);
|
|
60
|
+
console.log(`Migration 0006 on ${dbPath}`);
|
|
61
|
+
console.log(result.applied ? ' workspace_root_settings table created' : ' skipped: nothing to change');
|
|
62
|
+
process.exit(0);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error(`Migration 0006 failed: ${err?.message || err}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: storage for Claude Cloud conversations (provider `claude-cloud`).
|
|
5
|
+
*
|
|
6
|
+
* 1. `runtime_sessions.claude_cloud_session_id` — the cloud session a
|
|
7
|
+
* conversation runs in, reported by its worker after the first message
|
|
8
|
+
* (the per-provider native id, next to `claude_native_session_id`,
|
|
9
|
+
* `cursor_agent_id` and `grok_native_session_id`).
|
|
10
|
+
* 2. `runtime_sessions.claude_cloud_last_sequence` — the last event sequence
|
|
11
|
+
* the worker has handled: where it resumes the event stream after a restart.
|
|
12
|
+
* 3. `runtime_sessions.claude_cloud_cost_usd` — what the session has cost so
|
|
13
|
+
* far, as the cloud reports it.
|
|
14
|
+
* 4. `conversations.cloud_source_json` — what the session clones and where it
|
|
15
|
+
* is shown: `{ repoUrl, branch, environmentId, sessionUrl,
|
|
16
|
+
* pushedBranches: [{ branch, at }] }`.
|
|
17
|
+
*
|
|
18
|
+
* Additive and idempotent. Safe to run on every boot.
|
|
19
|
+
*
|
|
20
|
+
* Usage: node server/migrations/0007-claude-cloud.mjs [path/to/copilot.db]
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
import path from 'path';
|
|
25
|
+
import process from 'process';
|
|
26
|
+
import Database from 'better-sqlite3';
|
|
27
|
+
|
|
28
|
+
const COLUMNS = Object.freeze([
|
|
29
|
+
Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_session_id', type: 'TEXT' }),
|
|
30
|
+
Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_last_sequence', type: 'TEXT' }),
|
|
31
|
+
Object.freeze({ table: 'runtime_sessions', name: 'claude_cloud_cost_usd', type: 'REAL' }),
|
|
32
|
+
Object.freeze({ table: 'conversations', name: 'cloud_source_json', type: 'TEXT' }),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function tableExists(db, name) {
|
|
36
|
+
return !!db
|
|
37
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
38
|
+
.get(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function columnNames(db, table) {
|
|
42
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function migrateClaudeCloud(db) {
|
|
46
|
+
let columnsAdded = 0;
|
|
47
|
+
for (const column of COLUMNS) {
|
|
48
|
+
if (!tableExists(db, column.table)) continue;
|
|
49
|
+
if (columnNames(db, column.table).has(column.name)) continue;
|
|
50
|
+
db.exec(`ALTER TABLE ${column.table} ADD COLUMN ${column.name} ${column.type}`);
|
|
51
|
+
columnsAdded += 1;
|
|
52
|
+
}
|
|
53
|
+
return { applied: columnsAdded > 0, columnsAdded };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultDbPath() {
|
|
57
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
58
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function migrate(dbPath, options = {}) {
|
|
62
|
+
const db = new Database(dbPath, options);
|
|
63
|
+
try {
|
|
64
|
+
return migrateClaudeCloud(db);
|
|
65
|
+
} finally {
|
|
66
|
+
db.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
71
|
+
if (isMain) {
|
|
72
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
73
|
+
try {
|
|
74
|
+
const result = migrate(dbPath);
|
|
75
|
+
console.log(`Migration 0007 on ${dbPath}`);
|
|
76
|
+
console.log(result.applied ? ` claude cloud columns added: ${result.columnsAdded}` : ' skipped: nothing to change');
|
|
77
|
+
process.exit(0);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
console.error(`Migration 0007 failed: ${err?.message || err}`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|