@oar-sh/oar 0.9.20 → 0.9.22
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/CHANGELOG.md +325 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +16 -4
- package/server/claude-worker/claude-session-worker-main.mjs +18 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
- package/server/cursor-worker/cursor-session-worker.mjs +21 -2
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +10 -0
- package/server/grok-worker/grok-session-worker.mjs +22 -2
- package/server/grok-worker/grok-turn-runner.mjs +4 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +11 -2
- package/server/public/app/ask-user-view.js +24 -9
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +618 -72
- package/server/public/app/chat-actions-menu-state.mjs +141 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +309 -49
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +238 -0
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +88 -81
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +328 -58
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +251 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +67 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +47 -13
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +35 -8
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2603 -1767
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +33 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +92 -15
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +117 -12
- package/server/server-runtime.mjs +367 -170
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +84 -18
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +183 -31
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/build-id.mjs +128 -0
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +31 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -36,6 +36,7 @@ import { applySchema } from './db-schema.mjs';
|
|
|
36
36
|
import { createSessionRepository } from './repositories/session-repository.mjs';
|
|
37
37
|
import { createMessageRepository } from './repositories/message-repository.mjs';
|
|
38
38
|
import { createQuestionRepository } from './repositories/question-repository.mjs';
|
|
39
|
+
import { mapRelayActivityRow, mapRelayThoughtRow, sanitizeActivityText } from './repositories/relay-turn-rows.mjs';
|
|
39
40
|
import { createImageConversationRepository } from './repositories/image-conversation-repository.mjs';
|
|
40
41
|
import { registerSessionsRoutes } from './routes/sessions-routes.mjs';
|
|
41
42
|
import { buildDequeuedRelayMessage, claudeApiRemedyText, dequeuePendingMessageForWorkerLoop, registerMessagesRoutes } from './routes/messages-routes.mjs';
|
|
@@ -75,6 +76,9 @@ import { createDeepSeekUsageImportService } from './services/deepseek-usage-impo
|
|
|
75
76
|
import { createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
|
|
76
77
|
import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
|
|
77
78
|
import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
|
|
79
|
+
import { createWorkerReplacement, createWorkerReplacementFence } from './services/worker-replacement.mjs';
|
|
80
|
+
import { sessionLaunchMutex } from './services/session-launch-mutex-service.mjs';
|
|
81
|
+
import { createWorkerRecycleService, sweepArchivedWorkers } from './services/worker-recycle-service.mjs';
|
|
78
82
|
import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
|
|
79
83
|
import { createTokenUsageService } from './services/token-usage-service.mjs';
|
|
80
84
|
import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
|
|
@@ -119,7 +123,8 @@ import { createActiveDeviceTracker } from './services/push-active-device-service
|
|
|
119
123
|
import { registerPushRoutes } from './routes/push-routes.mjs';
|
|
120
124
|
import { registerAboutRoutes } from './routes/about-routes.mjs';
|
|
121
125
|
import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
|
|
122
|
-
import { createAboutService
|
|
126
|
+
import { createAboutService } from './services/about-service.mjs';
|
|
127
|
+
import { resolveBuildId } from '../shared/build-id.mjs';
|
|
123
128
|
import { createImageOperationService } from './services/image-operation-service.mjs';
|
|
124
129
|
import {
|
|
125
130
|
normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
|
|
@@ -229,6 +234,9 @@ import {
|
|
|
229
234
|
import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
|
|
230
235
|
import { createWorkerScope } from './services/worker-scope.mjs';
|
|
231
236
|
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
237
|
+
import { createArmableInterval, createCliPresence } from './services/idle-timers.mjs';
|
|
238
|
+
import { installQueueWakeHook } from './services/queue-wake-hook.mjs';
|
|
239
|
+
import { createPendingWorkerPrimer, createQueueWaker } from './services/queue-waker.mjs';
|
|
232
240
|
import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
|
|
233
241
|
import {
|
|
234
242
|
STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
|
|
@@ -266,7 +274,11 @@ import {
|
|
|
266
274
|
readTaskStallMinutesSetting,
|
|
267
275
|
} from '../shared/task-output-text.mjs';
|
|
268
276
|
import { normalizeRelayThoughtList } from './public/app/relay-thoughts.mjs';
|
|
269
|
-
import {
|
|
277
|
+
import {
|
|
278
|
+
capFinishedActivityEntries,
|
|
279
|
+
capRelayActivityEntries,
|
|
280
|
+
inFlightActivityCap,
|
|
281
|
+
} from './public/app/activity-replay-state.mjs';
|
|
270
282
|
import {
|
|
271
283
|
isOpenAIModelId,
|
|
272
284
|
isSafeClaudeModelId,
|
|
@@ -298,7 +310,12 @@ const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-che
|
|
|
298
310
|
// When this relay process started, and the commit its code came from (read
|
|
299
311
|
// once: a checkout moved on later still runs the code it started with).
|
|
300
312
|
const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
301
|
-
|
|
313
|
+
// The build id (version plus commit) names that code for the session workers:
|
|
314
|
+
// a worker that states another one started on older code. Read in the same
|
|
315
|
+
// pass as the commit so the two cannot disagree.
|
|
316
|
+
const RELAY_BUILD = resolveBuildId(PACKAGE_ROOT);
|
|
317
|
+
const BUILD_COMMIT = RELAY_BUILD.commit;
|
|
318
|
+
const BUILD_ID = RELAY_BUILD.id;
|
|
302
319
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
303
320
|
const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
|
|
304
321
|
const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
|
|
@@ -1996,18 +2013,36 @@ let runtimeShutdownStarted = false;
|
|
|
1996
2013
|
// Set once the database is open (see the retention sweep near the other sweeps).
|
|
1997
2014
|
let subagentRecordRetention = null;
|
|
1998
2015
|
const runtimeTimers = {
|
|
1999
|
-
cliStatus: null,
|
|
2000
2016
|
ownerWatchdog: null,
|
|
2001
2017
|
staleRecovery: null,
|
|
2002
2018
|
questionExpiry: null,
|
|
2003
|
-
pendingWorkerPrime: null,
|
|
2004
|
-
sharedViewerPrune: null,
|
|
2005
2019
|
restoredTaskSettle: null,
|
|
2006
2020
|
claudeApiCapClock: null,
|
|
2007
2021
|
memoryLog: null,
|
|
2008
2022
|
memoryWatch: null,
|
|
2023
|
+
// Once, 30 s after the relay listens: archived sessions' workers stopped.
|
|
2024
|
+
archivedWorkerSweep: null,
|
|
2009
2025
|
};
|
|
2010
2026
|
|
|
2027
|
+
// The worker prime and the detached-worker sweep, every 5 s while a pending
|
|
2028
|
+
// row waits for its worker (rows in retry backoff included, so the worker is
|
|
2029
|
+
// warm when the backoff matures). Armed at startup, by every prime that finds
|
|
2030
|
+
// a row and by every queue snapshot with a row pending; a pass that leaves no
|
|
2031
|
+
// such row disarms it. Declared up here so a prime early in the boot can arm
|
|
2032
|
+
// it; its tick runs only after the boot.
|
|
2033
|
+
const pendingWorkerPrimeTimer = createArmableInterval({
|
|
2034
|
+
intervalMs: 5_000,
|
|
2035
|
+
tick: async () => {
|
|
2036
|
+
await Promise.allSettled([
|
|
2037
|
+
primePendingSessionWorkers('maintenance'),
|
|
2038
|
+
detachedWorkerWatch.sweep().catch((error) => {
|
|
2039
|
+
console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
|
|
2040
|
+
}),
|
|
2041
|
+
]);
|
|
2042
|
+
return hasPendingWorkerOwnerRows();
|
|
2043
|
+
},
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2011
2046
|
function normalizeRemotePath(value) {
|
|
2012
2047
|
if (typeof value !== 'string') return '';
|
|
2013
2048
|
const trimmed = value.trim();
|
|
@@ -2850,6 +2885,16 @@ const stmts = {
|
|
|
2850
2885
|
...createQuestionRepository(db),
|
|
2851
2886
|
...createImageConversationRepository(db),
|
|
2852
2887
|
};
|
|
2888
|
+
// Every queue write that can make a row deliverable wakes the delivery
|
|
2889
|
+
// (services/queue-wake-hook.mjs): installed before any route or startup
|
|
2890
|
+
// write. The waker exists once the worker socket service does; a write
|
|
2891
|
+
// before that is covered by the startup prime and the service's start.
|
|
2892
|
+
// Without the hook (it could not be installed) the queue is polled.
|
|
2893
|
+
let queueWaker = null;
|
|
2894
|
+
const queueWakeHook = installQueueWakeHook(db, {
|
|
2895
|
+
onWake: (reason) => { queueWaker?.wake(reason, { prime: false }); },
|
|
2896
|
+
log: (line) => console.warn(`[queue-wake] ${line}`),
|
|
2897
|
+
});
|
|
2853
2898
|
const statusEventService = createStatusEventService(db);
|
|
2854
2899
|
// Public shares and their access log (migration 0016).
|
|
2855
2900
|
const shareRepository = createShareRepository(db);
|
|
@@ -3411,6 +3456,8 @@ const sessionWorkerSupervisor = createSessionWorkerSupervisor({
|
|
|
3411
3456
|
diagnosticPlanReference: () => path.join(currentWorkspaceRootPath(), '.cursor', 'plans', 'worker-startup-monitoring-plan.md'),
|
|
3412
3457
|
log: (message) => console.warn(`${runtimeLogPrefix()}${message}`),
|
|
3413
3458
|
onLaunchState: (event) => workerLaunchNoteService.handle(event),
|
|
3459
|
+
// A kill's grace ended: the session's rows may go to its next worker.
|
|
3460
|
+
onKillBlockLifted: () => { wakeSessionWorkerQueue('kill-block-lifted'); },
|
|
3414
3461
|
});
|
|
3415
3462
|
// Feature flags moved from config.json into app_settings; adopt any legacy
|
|
3416
3463
|
// `features` key once, then resolve the boot snapshot (defaults < stored rows
|
|
@@ -3811,7 +3858,11 @@ function relayShutdownStatePayload() {
|
|
|
3811
3858
|
|
|
3812
3859
|
function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
3813
3860
|
const result = relayRestartQueue.cancel({ requestedBy });
|
|
3814
|
-
if (result?.cancelled)
|
|
3861
|
+
if (result?.cancelled) {
|
|
3862
|
+
relayEventService?.noteRestartCancelled();
|
|
3863
|
+
// The queued restart held deliveries back: they go out now.
|
|
3864
|
+
wakeSessionWorkerQueue('restart-cancelled');
|
|
3865
|
+
}
|
|
3815
3866
|
return result;
|
|
3816
3867
|
}
|
|
3817
3868
|
|
|
@@ -5380,52 +5431,28 @@ function createCompactedConversation(sourceConversationId) {
|
|
|
5380
5431
|
};
|
|
5381
5432
|
}
|
|
5382
5433
|
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
}
|
|
5388
|
-
|
|
5389
|
-
function mapRelayActivityRow(row) {
|
|
5390
|
-
const text = sanitizeActivityText(row?.text);
|
|
5391
|
-
if (!text) return null;
|
|
5392
|
-
const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
|
|
5393
|
-
// Structured rows (compaction boundaries) carry their payload alongside the
|
|
5394
|
-
// prose so the transcript can promote them to a break row; junk JSON is
|
|
5395
|
-
// simply dropped back to a prose-only entry.
|
|
5396
|
-
let metadata = null;
|
|
5397
|
-
if (row?.metadata_json) {
|
|
5398
|
-
try {
|
|
5399
|
-
const parsed = JSON.parse(String(row.metadata_json));
|
|
5400
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) metadata = parsed;
|
|
5401
|
-
} catch { metadata = null; }
|
|
5402
|
-
}
|
|
5403
|
-
return {
|
|
5404
|
-
text,
|
|
5405
|
-
subagentRunId: subagentRunId || null,
|
|
5406
|
-
...(metadata ? { metadata } : {}),
|
|
5407
|
-
};
|
|
5408
|
-
}
|
|
5409
|
-
|
|
5410
|
-
const RELAY_ACTIVITY_ROW_LIMIT = 48;
|
|
5411
|
-
|
|
5434
|
+
// Per thread (the main agent, each sub-agent), the newest rows; structured
|
|
5435
|
+
// rows are always kept (activity-replay-state.mjs). A finished turn: the main
|
|
5436
|
+
// agent's 200, each sub-agent's 50, and a turn whose main agent ran more says
|
|
5437
|
+
// how many it left out.
|
|
5412
5438
|
function relayActivityForResponse(responseMessageId) {
|
|
5413
|
-
return
|
|
5439
|
+
return capFinishedActivityEntries(
|
|
5414
5440
|
stmts.listActivityByResponse
|
|
5415
5441
|
.all(responseMessageId)
|
|
5416
5442
|
.map(mapRelayActivityRow)
|
|
5417
5443
|
.filter(Boolean),
|
|
5418
|
-
RELAY_ACTIVITY_ROW_LIMIT,
|
|
5419
5444
|
);
|
|
5420
5445
|
}
|
|
5421
5446
|
|
|
5422
|
-
|
|
5447
|
+
// A running turn: as many per thread as the browser shows (`activityCap`, the
|
|
5448
|
+
// request's param; 50 when it sends none, 500 at most).
|
|
5449
|
+
function relayActivityForQueueMessage(queueMessageId, activityCap = undefined) {
|
|
5423
5450
|
return capRelayActivityEntries(
|
|
5424
5451
|
stmts.listActivityByQueueMessage
|
|
5425
5452
|
.all(queueMessageId)
|
|
5426
5453
|
.map(mapRelayActivityRow)
|
|
5427
5454
|
.filter(Boolean),
|
|
5428
|
-
|
|
5455
|
+
inFlightActivityCap(activityCap),
|
|
5429
5456
|
);
|
|
5430
5457
|
}
|
|
5431
5458
|
|
|
@@ -5447,19 +5474,6 @@ function relayStreamEventsForQueueMessage(queueMessageId) {
|
|
|
5447
5474
|
.slice(0, 5000);
|
|
5448
5475
|
}
|
|
5449
5476
|
|
|
5450
|
-
function mapRelayThoughtRow(row) {
|
|
5451
|
-
const seq = Math.max(0, Math.trunc(Number(row?.seq || 0)));
|
|
5452
|
-
const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
|
|
5453
|
-
return {
|
|
5454
|
-
reasoningId: row?.reasoning_id ? String(row.reasoning_id) : null,
|
|
5455
|
-
seq,
|
|
5456
|
-
text: String(row?.text || ''),
|
|
5457
|
-
done: Number(row?.done || 0) === 1,
|
|
5458
|
-
timestamp: row?.created_at || null,
|
|
5459
|
-
subagentRunId: subagentRunId || null,
|
|
5460
|
-
};
|
|
5461
|
-
}
|
|
5462
|
-
|
|
5463
5477
|
function relayThoughtsForResponse(responseMessageId) {
|
|
5464
5478
|
const rows = stmts.listThoughtsByResponse?.all(responseMessageId) || [];
|
|
5465
5479
|
return normalizeRelayThoughtList(rows.map(mapRelayThoughtRow)).slice(0, 5000);
|
|
@@ -5643,7 +5657,7 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5643
5657
|
stmts,
|
|
5644
5658
|
emit: (event, payload) => io.emit(event, payload),
|
|
5645
5659
|
// A follow-up resumed by hand is due at once: wake its worker.
|
|
5646
|
-
onQueueChanged: (reason) => {
|
|
5660
|
+
onQueueChanged: (reason) => { wakeSessionWorkerQueue(reason); },
|
|
5647
5661
|
logger: console,
|
|
5648
5662
|
});
|
|
5649
5663
|
|
|
@@ -5675,8 +5689,7 @@ const claudeBillingSwitchService = createClaudeBillingSwitchService({
|
|
|
5675
5689
|
// What the swap waits for: a turn, a question, a compaction, background tasks.
|
|
5676
5690
|
getPending: (runtime) => claudeBillingSwapService.pendingFor(runtime?.sdk_session_id || runtime?.conversation_id),
|
|
5677
5691
|
onQueueChanged: (reason) => {
|
|
5678
|
-
|
|
5679
|
-
sessionWorkerWebSocketService?.emitQueueChanged?.(reason);
|
|
5692
|
+
wakeSessionWorkerQueue(reason);
|
|
5680
5693
|
},
|
|
5681
5694
|
logger: console,
|
|
5682
5695
|
});
|
|
@@ -5724,8 +5737,7 @@ const restartResumeService = createRestartResumeService({
|
|
|
5724
5737
|
// The note is an ordinary pending message: the primer starts a stopped
|
|
5725
5738
|
// worker, the socket service hands it to a running one.
|
|
5726
5739
|
wake: () => {
|
|
5727
|
-
|
|
5728
|
-
sessionWorkerWebSocketService?.emitQueueChanged?.('restart-resume');
|
|
5740
|
+
wakeSessionWorkerQueue('restart-resume');
|
|
5729
5741
|
},
|
|
5730
5742
|
logger: console,
|
|
5731
5743
|
});
|
|
@@ -5755,8 +5767,7 @@ relayEventService = createRelayEventService({
|
|
|
5755
5767
|
// The event row is an ordinary pending message: the primer starts a
|
|
5756
5768
|
// stopped worker, the socket service hands it to a running one.
|
|
5757
5769
|
wakeWatcher: () => {
|
|
5758
|
-
|
|
5759
|
-
sessionWorkerWebSocketService?.emitQueueChanged?.('relay-event');
|
|
5770
|
+
wakeSessionWorkerQueue('relay-event');
|
|
5760
5771
|
},
|
|
5761
5772
|
relayName: () => remoteRelayRegistry?.selfName?.() || getPwaAppName?.() || '',
|
|
5762
5773
|
onRemoteChange: (relayId) => { void relayEventLinkService?.fetchNow(relayId); },
|
|
@@ -5772,8 +5783,7 @@ const relayScheduleService = createRelayScheduleService({
|
|
|
5772
5783
|
emit: (event, payload) => io.emit(event, payload),
|
|
5773
5784
|
// The scheduled message is an ordinary pending one, like a relay event's.
|
|
5774
5785
|
wake: () => {
|
|
5775
|
-
|
|
5776
|
-
sessionWorkerWebSocketService?.emitQueueChanged?.('relay-schedule');
|
|
5786
|
+
wakeSessionWorkerQueue('relay-schedule');
|
|
5777
5787
|
},
|
|
5778
5788
|
// A firing into another session is a prompt of the conversation that set
|
|
5779
5789
|
// it: the agents' loop guard counts it (and holds it once it wants the user).
|
|
@@ -5836,7 +5846,9 @@ function resolveLiveTurnQueueRow(conversationId) {
|
|
|
5836
5846
|
return byId.get(liveId) || rows[0];
|
|
5837
5847
|
}
|
|
5838
5848
|
|
|
5839
|
-
|
|
5849
|
+
// `activityCap`: the tool lines per thread the asking page shows (the
|
|
5850
|
+
// conversation route passes its `activityCap` param; others get the default).
|
|
5851
|
+
function inFlightStateForConversation(conversationId, { activityCap = undefined } = {}) {
|
|
5840
5852
|
const row = resolveLiveTurnQueueRow(conversationId);
|
|
5841
5853
|
if (!row) return null;
|
|
5842
5854
|
const streamEvents = relayStreamEventsForQueueMessage(row.id);
|
|
@@ -5848,7 +5860,7 @@ function inFlightStateForConversation(conversationId) {
|
|
|
5848
5860
|
mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
|
|
5849
5861
|
timestamp: row.timestamp || null,
|
|
5850
5862
|
processingAt: row.processing_at || null,
|
|
5851
|
-
activities: relayActivityForQueueMessage(row.id),
|
|
5863
|
+
activities: relayActivityForQueueMessage(row.id, activityCap),
|
|
5852
5864
|
thoughts: relayThoughtsForQueueMessage(row.id),
|
|
5853
5865
|
streamEvents,
|
|
5854
5866
|
streamDone: !!lastStreamEvent?.done,
|
|
@@ -6381,6 +6393,15 @@ const SHARED_VIEWER_MAX_GLOBAL = Number.isFinite(Number(config.sharedPresenceMax
|
|
|
6381
6393
|
? Math.max(SHARED_VIEWER_MAX_PER_CONVERSATION, Math.trunc(Number(config.sharedPresenceMaxGlobal)))
|
|
6382
6394
|
: 5_000;
|
|
6383
6395
|
const sharedViewersByConversation = new Map();
|
|
6396
|
+
// Armed when the first share viewer arrives, disarmed by the prune that
|
|
6397
|
+
// leaves none: a relay nobody watches through a share link has no timer.
|
|
6398
|
+
const sharedViewerPruneTimer = createArmableInterval({
|
|
6399
|
+
intervalMs: 5_000,
|
|
6400
|
+
tick: () => {
|
|
6401
|
+
pruneSharedViewerPresence();
|
|
6402
|
+
return sharedViewersByConversation.size > 0;
|
|
6403
|
+
},
|
|
6404
|
+
});
|
|
6384
6405
|
|
|
6385
6406
|
function sanitizeSharedViewerId(value) {
|
|
6386
6407
|
const id = String(value || '').trim().replace(/[^a-zA-Z0-9:_-]+/g, '');
|
|
@@ -6521,6 +6542,7 @@ function markSharedViewerPresence({ conversationId, token, viewerId } = {}) {
|
|
|
6521
6542
|
}
|
|
6522
6543
|
const before = viewers.size;
|
|
6523
6544
|
viewers.set(key, { lastSeenAt: now });
|
|
6545
|
+
sharedViewerPruneTimer.arm();
|
|
6524
6546
|
const watcherCount = viewers.size;
|
|
6525
6547
|
if (before !== watcherCount) {
|
|
6526
6548
|
emitConversationWatcherCount(convId);
|
|
@@ -6550,6 +6572,23 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
6550
6572
|
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
6551
6573
|
};
|
|
6552
6574
|
}
|
|
6575
|
+
// The process asking is being replaced (the worker recycle passed its holds
|
|
6576
|
+
// for it): no work, as for a killed session; the worker stays ready, and
|
|
6577
|
+
// what waits goes to the new one. Unlike the killed marker above, this
|
|
6578
|
+
// survives the marker's grace and every clearRestartSchedule.
|
|
6579
|
+
if (workerReplacementFence.isHeld(requesterSessionId, pid)) {
|
|
6580
|
+
return {
|
|
6581
|
+
message: null,
|
|
6582
|
+
routing: {
|
|
6583
|
+
enabled: true,
|
|
6584
|
+
requesterSessionId,
|
|
6585
|
+
blockedReason: 'worker-replacement',
|
|
6586
|
+
lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
|
|
6587
|
+
fallbackRestart: null,
|
|
6588
|
+
},
|
|
6589
|
+
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
6590
|
+
};
|
|
6591
|
+
}
|
|
6553
6592
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
6554
6593
|
if (runtimeState.relayPaused) {
|
|
6555
6594
|
return { message: null, paused: true, reason: 'relay_paused' };
|
|
@@ -6629,6 +6668,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
6629
6668
|
workerKind: workerKind || sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind || null,
|
|
6630
6669
|
claudeApiClaimCheck,
|
|
6631
6670
|
onBillingFence: handleClaudeBillingFence,
|
|
6671
|
+
// The fence check above, asked again before every claim: ensureWorker
|
|
6672
|
+
// and a transient error's backoff both wait.
|
|
6673
|
+
isReplacementFenced: () => workerReplacementFence.isHeld(requesterSessionId, pid),
|
|
6632
6674
|
});
|
|
6633
6675
|
if (!dequeueResult?.message) {
|
|
6634
6676
|
return {
|
|
@@ -6777,7 +6819,7 @@ async function recoverUndeliveredSessionWorkerMessage({ pending = null, sessionI
|
|
|
6777
6819
|
console.warn(`${runtimeLogPrefix()}WS REQUEUE ${messageId.slice(0, 8)} session=${requesterSessionId ? requesterSessionId.slice(0, 8) : 'unknown'} retry=${retryCount} reason=${String(reason || 'ws-send-failed')}`);
|
|
6778
6820
|
io.emit('message_status', { messageId, conversationId: row.conversation_id, status: 'pending' });
|
|
6779
6821
|
io.emit('queue_updated', { recovered: 1, reason: 'worker-ws-send-failed' });
|
|
6780
|
-
|
|
6822
|
+
wakeSessionWorkerQueue('worker-ws-send-failed');
|
|
6781
6823
|
return true;
|
|
6782
6824
|
}
|
|
6783
6825
|
|
|
@@ -6867,11 +6909,10 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
|
6867
6909
|
}
|
|
6868
6910
|
if (recovered > 0) {
|
|
6869
6911
|
io.emit('queue_updated', { recovered, reason: `worker-died:${reason}` });
|
|
6870
|
-
sessionWorkerWebSocketService?.emitQueueChanged?.(`worker-died:${reason}`);
|
|
6871
6912
|
}
|
|
6872
6913
|
sessionWorkerSupervisor?.markError?.(sid, `worker-died:${reason}`);
|
|
6873
6914
|
// The rows stay owned, so the primer is what respawns their worker.
|
|
6874
|
-
|
|
6915
|
+
wakeSessionWorkerQueue(`worker-died:${reason}`);
|
|
6875
6916
|
return recovered;
|
|
6876
6917
|
}
|
|
6877
6918
|
|
|
@@ -6942,48 +6983,50 @@ async function sweepDeadWorkerProcessingRowsOnce() {
|
|
|
6942
6983
|
// claude-billing-swap-service.mjs): a Claude chat's next row of the other
|
|
6943
6984
|
// billing is refused to the worker it has, which is replaced, once idle, by
|
|
6944
6985
|
// one of the row's kind. The old one is stopped like the kill route does, and
|
|
6945
|
-
// the new one launched only once every process of the session is gone
|
|
6986
|
+
// the new one launched only once every process of the session is gone
|
|
6987
|
+
// (services/worker-replacement.mjs, which the worker recycle below shares).
|
|
6988
|
+
// The two get one instance each so a failed stop's warning names the caller.
|
|
6989
|
+
// They share one replacement fence, the one the deliveries check (the socket
|
|
6990
|
+
// delivery above and /api/pending): a process being replaced is handed no
|
|
6991
|
+
// work. The worker recycle raises it; the billing swap does not, since its
|
|
6992
|
+
// own delivery fence already keeps the other billing's rows from the old
|
|
6993
|
+
// worker.
|
|
6994
|
+
// A lifted fence (the replacement launched, or its stop failed and the old
|
|
6995
|
+
// worker serves again) hands out what waited behind it.
|
|
6996
|
+
const workerReplacementFence = (() => {
|
|
6997
|
+
const fence = createWorkerReplacementFence();
|
|
6998
|
+
return {
|
|
6999
|
+
...fence,
|
|
7000
|
+
release(sid, pid) {
|
|
7001
|
+
const released = fence.release(sid, pid);
|
|
7002
|
+
if (released) wakeSessionWorkerQueue('replacement-fence-lifted');
|
|
7003
|
+
return released;
|
|
7004
|
+
},
|
|
7005
|
+
};
|
|
7006
|
+
})();
|
|
7007
|
+
function createRuntimeWorkerReplacement(tag) {
|
|
7008
|
+
return createWorkerReplacement({
|
|
7009
|
+
registry: sessionWorkerRegistry,
|
|
7010
|
+
supervisor: sessionWorkerSupervisor,
|
|
7011
|
+
processInspector: sessionWorkerProcessInspector,
|
|
7012
|
+
stopSessionWorkerProcesses,
|
|
7013
|
+
isProcessAlive,
|
|
7014
|
+
killTmuxSession,
|
|
7015
|
+
logger: console,
|
|
7016
|
+
logPrefix: () => `${runtimeLogPrefix()}${tag} `,
|
|
7017
|
+
fence: workerReplacementFence,
|
|
7018
|
+
});
|
|
7019
|
+
}
|
|
7020
|
+
// A stop that fails backs off (the swap service retries it) and clears the
|
|
7021
|
+
// killed marker, so the session's worker, alive or relaunched, keeps asking
|
|
7022
|
+
// for work meanwhile. The fence still keeps the other billing's rows from it.
|
|
7023
|
+
const billingSwapWorkerReplacement = createRuntimeWorkerReplacement('BILLING SWAP');
|
|
6946
7024
|
const claudeBillingSwapService = createClaudeBillingSwapService({
|
|
6947
7025
|
getWorker: (sid) => sessionWorkerRegistry?.getWorker?.(sid) || null,
|
|
6948
7026
|
// A row the session claimed and has not finished: never stopped under it.
|
|
6949
7027
|
hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
|
|
6950
|
-
stopWorker:
|
|
6951
|
-
|
|
6952
|
-
// Nothing may start the session while it is being stopped: a launch then
|
|
6953
|
-
// would adopt the old process, kind and all.
|
|
6954
|
-
sessionWorkerSupervisor?.markKilled?.(sid);
|
|
6955
|
-
// A stop that fails backs off (the swap service retries it): the killed
|
|
6956
|
-
// marker goes with it, so the session's worker, alive or relaunched,
|
|
6957
|
-
// keeps asking for work meanwhile. The fence still keeps the other
|
|
6958
|
-
// billing's rows from it.
|
|
6959
|
-
const backOff = (error, remainingPids = []) => {
|
|
6960
|
-
sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
6961
|
-
console.warn(`${runtimeLogPrefix()}BILLING SWAP STOP FAILED session=${sid.slice(0, 8)} ${error}${remainingPids.length ? ` remaining=${remainingPids.join(',')}` : ''} — kill marker cleared, retrying with a backoff`);
|
|
6962
|
-
return { ok: false, error, remainingPids };
|
|
6963
|
-
};
|
|
6964
|
-
let stopped = null;
|
|
6965
|
-
try {
|
|
6966
|
-
await sessionWorkerSupervisor?.cancelPendingStart?.(sid, { wait: true });
|
|
6967
|
-
stopped = await stopSessionWorkerProcesses({
|
|
6968
|
-
sdkSessionId: sid,
|
|
6969
|
-
worker,
|
|
6970
|
-
processInspector: sessionWorkerProcessInspector,
|
|
6971
|
-
isPidAliveImpl: isProcessAlive,
|
|
6972
|
-
killTmuxSessionImpl: killTmuxSession,
|
|
6973
|
-
});
|
|
6974
|
-
} catch (error) {
|
|
6975
|
-
return backOff(String(error?.message || error || 'worker-stop-failed'));
|
|
6976
|
-
}
|
|
6977
|
-
if (!stopped.ok) return backOff(stopped.error || 'worker-survived-stop', stopped.remainingPids || []);
|
|
6978
|
-
sessionWorkerRegistry?.removeWorker?.(sid);
|
|
6979
|
-
sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
|
|
6980
|
-
return { ok: true };
|
|
6981
|
-
},
|
|
6982
|
-
launchWorker: async (sid) => {
|
|
6983
|
-
sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
6984
|
-
const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
|
|
6985
|
-
return { ok: launched?.ok === true, error: launched?.error || null };
|
|
6986
|
-
},
|
|
7028
|
+
stopWorker: billingSwapWorkerReplacement.stopWorker,
|
|
7029
|
+
launchWorker: billingSwapWorkerReplacement.launchWorker,
|
|
6987
7030
|
onStuck: ({ conversationId, rowBilling, attempts, reason }) => {
|
|
6988
7031
|
if (!conversationId) return;
|
|
6989
7032
|
const at = new Date().toISOString();
|
|
@@ -7015,6 +7058,95 @@ const claudeBillingSwapService = createClaudeBillingSwapService({
|
|
|
7015
7058
|
logger: console,
|
|
7016
7059
|
});
|
|
7017
7060
|
|
|
7061
|
+
// Whether a worker's session is one the relay keeps no worker for: its
|
|
7062
|
+
// conversation is archived (or deleted), or there is no conversation behind
|
|
7063
|
+
// the session at all. A conversation in use found either way (the runtime
|
|
7064
|
+
// session's, or the one naming the session id) keeps its worker: a session
|
|
7065
|
+
// is never stopped for good on a missing link alone.
|
|
7066
|
+
function isWorkerSessionArchived(sid) {
|
|
7067
|
+
const runtime = stmts.getRuntimeSessionBySdkSessionId?.get?.(sid) || null;
|
|
7068
|
+
const viaRuntime = runtime?.conversation_id ? (stmts.getConvAnyStatus?.get?.(runtime.conversation_id) || null) : null;
|
|
7069
|
+
const viaSessionId = stmts.getConvBySdkSessionId?.get?.(sid) || null;
|
|
7070
|
+
const inUse = (conversation) => Boolean(conversation)
|
|
7071
|
+
&& Number(conversation.archived || 0) !== 1
|
|
7072
|
+
&& String(conversation.status || '').trim().toLowerCase() !== 'deleted';
|
|
7073
|
+
return !inUse(viaRuntime) && !inUse(viaSessionId);
|
|
7074
|
+
}
|
|
7075
|
+
|
|
7076
|
+
// The worker recycle (services/worker-recycle-service.mjs): a worker that
|
|
7077
|
+
// runs older code than this relay (another build id, or none stated) is
|
|
7078
|
+
// replaced between turns by one on this relay's code; an archived session's
|
|
7079
|
+
// is stopped for good. Its own replacement instance, so a failed stop says
|
|
7080
|
+
// WORKER RECYCLE rather than BILLING SWAP.
|
|
7081
|
+
const recycleWorkerReplacement = createRuntimeWorkerReplacement('WORKER RECYCLE');
|
|
7082
|
+
const workerRecycleService = createWorkerRecycleService({
|
|
7083
|
+
relayBuildId: BUILD_ID,
|
|
7084
|
+
listWorkers: () => sessionWorkerRegistry.listWorkers(),
|
|
7085
|
+
getWorker: (sid) => sessionWorkerRegistry.getWorker(sid),
|
|
7086
|
+
// A row the session claimed and has not finished: never stopped under it.
|
|
7087
|
+
hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
|
|
7088
|
+
// From the supervisor's lifecycle, not its firstObservedHeartbeatAt: that
|
|
7089
|
+
// is set only for a worker it launched itself, and the workers that
|
|
7090
|
+
// reconnect after a relay restart (the ones most likely stale) never get it.
|
|
7091
|
+
hasHeartbeated: (sid) => {
|
|
7092
|
+
const lc = sessionWorkerSupervisor.getLifecycleState(sid);
|
|
7093
|
+
return !!lc?.lastHeartbeatAt && !lc?.awaitingHeartbeat;
|
|
7094
|
+
},
|
|
7095
|
+
isArchived: (sid) => isWorkerSessionArchived(sid),
|
|
7096
|
+
// The session's last delivery or turn end, for a worker that does not say
|
|
7097
|
+
// whether its CLI child runs (message-repository getOwnerLastActivityAt).
|
|
7098
|
+
quietSince: (sid) => {
|
|
7099
|
+
const at = Date.parse(stmts.getOwnerLastActivityAt?.get?.(sid)?.last_activity_at || '');
|
|
7100
|
+
return Number.isFinite(at) ? at : null;
|
|
7101
|
+
},
|
|
7102
|
+
stopWorker: recycleWorkerReplacement.stopWorker,
|
|
7103
|
+
launchWorker: recycleWorkerReplacement.launchWorker,
|
|
7104
|
+
fence: recycleWorkerReplacement.fence,
|
|
7105
|
+
// The stop and the launch under the per-session launch mutex the Change
|
|
7106
|
+
// CWD relaunch and the launch routes take (sessions-routes.mjs).
|
|
7107
|
+
runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
|
|
7108
|
+
logger: console,
|
|
7109
|
+
});
|
|
7110
|
+
|
|
7111
|
+
// Once at start, after the restart's own worker reconnects settled: what
|
|
7112
|
+
// still runs for an archived conversation is stopped and stays stopped.
|
|
7113
|
+
// Through the recycle's replacement, one session at a time; never throws.
|
|
7114
|
+
async function sweepArchivedSessionWorkersAtStart() {
|
|
7115
|
+
if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return null;
|
|
7116
|
+
if (runtimeShutdownStarted) return null;
|
|
7117
|
+
// The first lookup reads the process list afresh; the rest share it for the
|
|
7118
|
+
// inspector's cache moment, so a long archive costs one read, not one each.
|
|
7119
|
+
let freshRead = true;
|
|
7120
|
+
return sweepArchivedWorkers({
|
|
7121
|
+
listArchivedSessionIds: () => (stmts.listArchivedRuntimeSessionIds?.all?.() || []).map((row) => row?.sdk_session_id),
|
|
7122
|
+
hasLiveProcess: async (sid) => {
|
|
7123
|
+
if (runtimeShutdownStarted) return false;
|
|
7124
|
+
const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
|
|
7125
|
+
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return true;
|
|
7126
|
+
const fresh = freshRead;
|
|
7127
|
+
freshRead = false;
|
|
7128
|
+
return Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid, { fresh }));
|
|
7129
|
+
},
|
|
7130
|
+
isArchived: (sid) => isWorkerSessionArchived(sid),
|
|
7131
|
+
// The process the fence keeps deliveries from during the stop: the
|
|
7132
|
+
// registry's live pid, else the one the process list shows, else none
|
|
7133
|
+
// (every process of the session is fenced then).
|
|
7134
|
+
workerPid: async (sid) => {
|
|
7135
|
+
const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
|
|
7136
|
+
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return pid;
|
|
7137
|
+
const found = await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid);
|
|
7138
|
+
return Number(found?.processId) || null;
|
|
7139
|
+
},
|
|
7140
|
+
stopWorker: recycleWorkerReplacement.stopWorker,
|
|
7141
|
+
// Guarded as the recycle's archived stop is: a message that unarchives
|
|
7142
|
+
// the session meanwhile must not start a turn on the process being
|
|
7143
|
+
// stopped.
|
|
7144
|
+
fence: workerReplacementFence,
|
|
7145
|
+
runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
|
|
7146
|
+
logger: console,
|
|
7147
|
+
});
|
|
7148
|
+
}
|
|
7149
|
+
|
|
7018
7150
|
// The Claude API monthly cap (plan decision 12): `{ reached, error? }`. The
|
|
7019
7151
|
// one seam the claim check below, the billing switch, an agent's send and a
|
|
7020
7152
|
// new Claude API chat ask: this month's priced estimate in
|
|
@@ -7178,6 +7310,21 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
|
|
|
7178
7310
|
},
|
|
7179
7311
|
onDeliverySendFailed: recoverUndeliveredSessionWorkerMessage,
|
|
7180
7312
|
onWorkerSocketClosed: handleWorkerSocketClosed,
|
|
7313
|
+
// The CLI presence window is wider while a worker socket is open.
|
|
7314
|
+
onSocketCountChange: (count) => cliPresence.noteWorkerSocketCount(count),
|
|
7315
|
+
// A row pending (in backoff too) keeps the worker prime armed, and the
|
|
7316
|
+
// poll primes now and then while rows wait.
|
|
7317
|
+
onQueueSnapshot: (snapshot) => {
|
|
7318
|
+
if (snapshot.pendingCount > 0) queueWaker?.primeFromBackstop('backstop');
|
|
7319
|
+
},
|
|
7320
|
+
// A retry time or a usage-limit hold coming due wakes the queue on time.
|
|
7321
|
+
nextDueAtMs: () => {
|
|
7322
|
+
if (runtimeShutdownStarted) return null;
|
|
7323
|
+
const due = stmts.nextPendingAttemptAt?.get?.(new Date().toISOString())?.due;
|
|
7324
|
+
const dueMs = due ? Date.parse(due) : NaN;
|
|
7325
|
+
return Number.isFinite(dueMs) ? dueMs : null;
|
|
7326
|
+
},
|
|
7327
|
+
onDue: (reason) => { wakeSessionWorkerQueue(reason); },
|
|
7181
7328
|
requestWork: requestSessionWorkerSocketDelivery,
|
|
7182
7329
|
// What a worker says it is ("claude", "claude-api", ...): the registry
|
|
7183
7330
|
// keeps it for the billing fence and for what a response ran on.
|
|
@@ -7187,17 +7334,45 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
|
|
|
7187
7334
|
const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
|
|
7188
7335
|
if (worker && worker.kind !== kind) sessionWorkerRegistry?.upsertWorker?.({ ...worker, kind });
|
|
7189
7336
|
},
|
|
7337
|
+
// The build id a worker says it started on: the registry keeps it so a
|
|
7338
|
+
// worker on older code can be told from one on the relay's.
|
|
7339
|
+
onWorkerBuildId: (sessionId, buildId) => {
|
|
7340
|
+
const sid = String(sessionId || '').trim();
|
|
7341
|
+
if (!sid) return;
|
|
7342
|
+
const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
|
|
7343
|
+
if (worker && worker.buildId !== buildId) sessionWorkerRegistry?.upsertWorker?.({ ...worker, buildId });
|
|
7344
|
+
},
|
|
7345
|
+
// Sent in server.hello, so a worker on other code can say so in its log.
|
|
7346
|
+
buildId: BUILD_ID,
|
|
7190
7347
|
isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
|
|
7191
7348
|
pathPrefix: remotePath,
|
|
7192
|
-
|
|
7349
|
+
// A backstop under the queue's own wakes; the poll every 500 ms it was
|
|
7350
|
+
// when those cannot be installed.
|
|
7351
|
+
pollIntervalMs: queueWakeHook.installed ? 10_000 : 500,
|
|
7193
7352
|
logger: console,
|
|
7194
7353
|
});
|
|
7354
|
+
let queueWakeStarted = false;
|
|
7355
|
+
queueWaker = createQueueWaker({
|
|
7356
|
+
prime: (reason) => primePendingSessionWorkers(reason),
|
|
7357
|
+
notify: (reason) => {
|
|
7358
|
+
sessionWorkerWebSocketService.notifyQueueChanged(reason).catch((error) => {
|
|
7359
|
+
console.warn(`${runtimeLogPrefix()}QUEUE WAKE failed reason=${reason} err=${error?.message || error}`);
|
|
7360
|
+
});
|
|
7361
|
+
},
|
|
7362
|
+
armPrime: () => pendingWorkerPrimeTimer.arm(),
|
|
7363
|
+
isReady: () => queueWakeStarted && !runtimeShutdownStarted,
|
|
7364
|
+
warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
|
|
7365
|
+
});
|
|
7195
7366
|
runtimeTimers.deadWorkerSweep = setInterval(() => {
|
|
7196
7367
|
sweepDeadWorkerProcessingRows().catch((error) => {
|
|
7197
7368
|
console.warn(`${runtimeLogPrefix()}WORKER SWEEP failed: ${error?.message || error}`);
|
|
7198
7369
|
});
|
|
7199
7370
|
}, 30_000);
|
|
7200
7371
|
if (typeof runtimeTimers.deadWorkerSweep.unref === 'function') runtimeTimers.deadWorkerSweep.unref();
|
|
7372
|
+
// Workers on older code are recycled only where the relay launches them;
|
|
7373
|
+
// with routing off the service never starts. Its timer is its own (unref'd)
|
|
7374
|
+
// and stops with the runtime timers.
|
|
7375
|
+
if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) workerRecycleService.start();
|
|
7201
7376
|
const tmuxInspectorAccessPolicy = createTmuxInspectorAccessPolicy({
|
|
7202
7377
|
sessionWorkerRegistry,
|
|
7203
7378
|
sessionWorkerSupervisor,
|
|
@@ -7214,40 +7389,39 @@ const tmuxInspectorSocketService = createTmuxInspectorSocketService({
|
|
|
7214
7389
|
logger: console,
|
|
7215
7390
|
});
|
|
7216
7391
|
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
|
|
7222
|
-
|
|
7223
|
-
|
|
7224
|
-
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
|
|
7249
|
-
|
|
7250
|
-
return requested;
|
|
7392
|
+
// No next_attempt_at filter in the listing: rows sitting in retry backoff
|
|
7393
|
+
// still need their worker warm, otherwise the poll wins the race the moment
|
|
7394
|
+
// the backoff matures and the turn executes on the wrong provider.
|
|
7395
|
+
const pendingWorkerPrimer = createPendingWorkerPrimer({
|
|
7396
|
+
isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
|
|
7397
|
+
&& !runtimeShutdownStarted
|
|
7398
|
+
&& typeof sessionWorkerSupervisor?.ensureWorker === 'function'
|
|
7399
|
+
&& typeof stmts.listPendingWorkerOwnerSessionIds?.all === 'function',
|
|
7400
|
+
listPendingOwnerSessionIds: (limit) => stmts.listPendingWorkerOwnerSessionIds.all(limit),
|
|
7401
|
+
isKillBlocked: (sid) => sessionWorkerSupervisor?.isKillBlocked?.(sid) === true,
|
|
7402
|
+
failRowsWithoutLaunch: (sid) => failClaudeApiRowsWithoutLaunch(sid),
|
|
7403
|
+
ensureWorker: (sid) => sessionWorkerSupervisor.ensureWorker(sid),
|
|
7404
|
+
// Rows waiting for their worker: the maintenance pass retries until none is.
|
|
7405
|
+
onRowsWaiting: () => pendingWorkerPrimeTimer.arm(),
|
|
7406
|
+
warn: (line) => console.warn(`${runtimeLogPrefix()}${line}`),
|
|
7407
|
+
});
|
|
7408
|
+
|
|
7409
|
+
function primePendingSessionWorkers(reason = 'pending-worker-prime') {
|
|
7410
|
+
return pendingWorkerPrimer(reason);
|
|
7411
|
+
}
|
|
7412
|
+
|
|
7413
|
+
/**
|
|
7414
|
+
* The queue may hold work for a worker now (a row written, a gate lifted, a
|
|
7415
|
+
* retry come due): hand rows to the ready workers, prime the sessions with
|
|
7416
|
+
* pending rows, keep the maintenance pass armed.
|
|
7417
|
+
*/
|
|
7418
|
+
function wakeSessionWorkerQueue(reason = 'queue-wake') {
|
|
7419
|
+
return queueWaker?.wake(reason) === true;
|
|
7420
|
+
}
|
|
7421
|
+
|
|
7422
|
+
function hasPendingWorkerOwnerRows() {
|
|
7423
|
+
if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true || runtimeShutdownStarted) return false;
|
|
7424
|
+
return (stmts.listPendingWorkerOwnerSessionIds?.all?.(1) || []).length > 0;
|
|
7251
7425
|
}
|
|
7252
7426
|
|
|
7253
7427
|
// Guard first, so a tunnelled request to a worker path is rejected before any
|
|
@@ -7351,16 +7525,35 @@ app.get(['/share-preview/:conversationId', '/share-preview/:conversationId/'], (
|
|
|
7351
7525
|
app.use(express.static(PUBLIC_DIR, { etag: false, setHeaders: (res) => res.setHeader('Cache-Control', 'no-store') }));
|
|
7352
7526
|
|
|
7353
7527
|
// ─── CLI Status Tracking ──────────────────────────────────────────────────────
|
|
7354
|
-
|
|
7528
|
+
// When the workers were last heard from, without a polling timer: the window
|
|
7529
|
+
// is 45 s while a worker socket is open (an idle worker beats every 30 s) and
|
|
7530
|
+
// 10 s once none is (services/idle-timers.mjs).
|
|
7531
|
+
const cliPresence = createCliPresence({
|
|
7532
|
+
onExpired: () => {
|
|
7533
|
+
if (cliOnline) markCliOffline('heartbeat-timeout');
|
|
7534
|
+
},
|
|
7535
|
+
});
|
|
7355
7536
|
let cliOnline = false;
|
|
7356
7537
|
let relayPaused = false;
|
|
7357
7538
|
const relayBridgeOwnerService = createRelayBridgeOwnerService();
|
|
7358
7539
|
|
|
7359
7540
|
const runtimeState = {
|
|
7541
|
+
// The relay's own build id, read once at start; null when package.json
|
|
7542
|
+
// names no version.
|
|
7543
|
+
buildId: BUILD_ID,
|
|
7544
|
+
// The worker recycle's status() (the relay's build id, the stale count,
|
|
7545
|
+
// the recycles in flight and done, who waits on what): a function, so a
|
|
7546
|
+
// route reads it when asked and needs no wiring of its own.
|
|
7547
|
+
workerRecycle: () => workerRecycleService.status(),
|
|
7360
7548
|
get cliOnline() { return cliOnline; },
|
|
7361
7549
|
set cliOnline(value) { cliOnline = value; },
|
|
7362
7550
|
get relayPaused() { return relayPaused; },
|
|
7363
|
-
set relayPaused(value) {
|
|
7551
|
+
set relayPaused(value) {
|
|
7552
|
+
const wasPaused = relayPaused;
|
|
7553
|
+
relayPaused = value;
|
|
7554
|
+
// Resumed: what was queued while paused goes out now.
|
|
7555
|
+
if (wasPaused && !relayPaused) wakeSessionWorkerQueue('relay-resumed');
|
|
7556
|
+
},
|
|
7364
7557
|
get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
|
|
7365
7558
|
get relayShutdown() { return relayRestartQueue.getState(); },
|
|
7366
7559
|
// A shutdown or restart waits for the processing turns: no pending row
|
|
@@ -7693,6 +7886,8 @@ const sharedRouteDeps = {
|
|
|
7693
7886
|
sessionWorkerSupervisor,
|
|
7694
7887
|
sessionWorkerRegistry,
|
|
7695
7888
|
sessionWorkerProcessInspector,
|
|
7889
|
+
// /api/pending refuses work to a process the worker recycle is replacing.
|
|
7890
|
+
workerReplacementFence,
|
|
7696
7891
|
buildContextResponseText,
|
|
7697
7892
|
readContextFromSessionEvents,
|
|
7698
7893
|
sessionHistoryRefreshService,
|
|
@@ -7859,6 +8054,9 @@ registerAboutRoutes(app, {
|
|
|
7859
8054
|
dbPath: DB_PATH,
|
|
7860
8055
|
version: RUNNING_VERSION,
|
|
7861
8056
|
commit: BUILD_COMMIT,
|
|
8057
|
+
// The workers' build ids are counted against this one (the About modal's
|
|
8058
|
+
// "Workers: 14, 3 on an older build").
|
|
8059
|
+
buildId: BUILD_ID,
|
|
7862
8060
|
installMethod: INSTALL_METHOD,
|
|
7863
8061
|
startedAt: RELAY_STARTED_AT,
|
|
7864
8062
|
packageRoot: PACKAGE_ROOT,
|
|
@@ -7879,7 +8077,7 @@ registerAboutRoutes(app, {
|
|
|
7879
8077
|
}),
|
|
7880
8078
|
});
|
|
7881
8079
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
7882
|
-
|
|
8080
|
+
cliPresence.clear();
|
|
7883
8081
|
const wasOnline = cliOnline;
|
|
7884
8082
|
cliOnline = false;
|
|
7885
8083
|
if (clearOwner) {
|
|
@@ -7893,20 +8091,6 @@ function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
|
7893
8091
|
}
|
|
7894
8092
|
}
|
|
7895
8093
|
|
|
7896
|
-
function checkCliStatus() {
|
|
7897
|
-
const wasOnline = cliOnline;
|
|
7898
|
-
cliOnline = cliLastSeen !== null && (Date.now() - cliLastSeen) < 10_000;
|
|
7899
|
-
if (wasOnline !== cliOnline) {
|
|
7900
|
-
if (!cliOnline) {
|
|
7901
|
-
markCliOffline('heartbeat-timeout');
|
|
7902
|
-
return;
|
|
7903
|
-
}
|
|
7904
|
-
console.log(`${runtimeLogPrefix()}CLI ONLINE`);
|
|
7905
|
-
io.emit('cli_status', { online: true });
|
|
7906
|
-
}
|
|
7907
|
-
}
|
|
7908
|
-
runtimeTimers.cliStatus = setInterval(checkCliStatus, 2000);
|
|
7909
|
-
|
|
7910
8094
|
if (managedOwnerPid) {
|
|
7911
8095
|
runtimeTimers.ownerWatchdog = setInterval(() => {
|
|
7912
8096
|
if (isProcessAlive(managedOwnerPid)) return;
|
|
@@ -7936,6 +8120,10 @@ const staleRecoveryClockGuard = createStaleRecoveryClockGuard({
|
|
|
7936
8120
|
});
|
|
7937
8121
|
function recoverStaleMessages() {
|
|
7938
8122
|
if (staleRecoveryClockGuard.tick().skip) return;
|
|
8123
|
+
// The tick itself stays: the guard reads the gaps between ticks, and a timer
|
|
8124
|
+
// that slept while nothing ran would look like a clock jump. Only the work
|
|
8125
|
+
// is skipped while no row is processing and no restart outcome is open.
|
|
8126
|
+
if (!stmts.hasProcessingRow.get() && !restartResumeService.pending().length) return;
|
|
7939
8127
|
const staleWindowMs = cliOnline
|
|
7940
8128
|
? processingTimeoutMs
|
|
7941
8129
|
: Math.min(processingTimeoutMs, OFFLINE_STALE_RECOVER_MS);
|
|
@@ -7975,8 +8163,6 @@ function expirePendingQuestions() {
|
|
|
7975
8163
|
}
|
|
7976
8164
|
runtimeTimers.questionExpiry = setInterval(expirePendingQuestions, 10_000);
|
|
7977
8165
|
expirePendingQuestions();
|
|
7978
|
-
runtimeTimers.sharedViewerPrune = setInterval(pruneSharedViewerPresence, 5_000);
|
|
7979
|
-
if (typeof runtimeTimers.sharedViewerPrune.unref === 'function') runtimeTimers.sharedViewerPrune.unref();
|
|
7980
8166
|
// Memory in the status log: now, every 10 minutes, and once per crossing of
|
|
7981
8167
|
// 80 % of the heap limit (relay-heap-guard.mjs).
|
|
7982
8168
|
{
|
|
@@ -8208,7 +8394,7 @@ function emitToClientsExceptSessionId(event, payload, sessionId) {
|
|
|
8208
8394
|
// ─── CLI Routes ───────────────────────────────────────────────────────────────
|
|
8209
8395
|
|
|
8210
8396
|
function touchCli() {
|
|
8211
|
-
|
|
8397
|
+
cliPresence.touch();
|
|
8212
8398
|
relayRestartOrchestrator.noteCliOnline();
|
|
8213
8399
|
if (!cliOnline) {
|
|
8214
8400
|
cliOnline = true;
|
|
@@ -8217,6 +8403,7 @@ function touchCli() {
|
|
|
8217
8403
|
}
|
|
8218
8404
|
}
|
|
8219
8405
|
sessionWorkerWebSocketService.start();
|
|
8406
|
+
queueWakeStarted = true;
|
|
8220
8407
|
void primePendingSessionWorkers('startup');
|
|
8221
8408
|
// Undelivered events get their timers back; stalled, all_idle and the
|
|
8222
8409
|
// heartbeat are swept from here on.
|
|
@@ -8229,13 +8416,7 @@ relayScheduleService.start();
|
|
|
8229
8416
|
relayAskLinkService.start();
|
|
8230
8417
|
// Paired relays' Inboxes, every ~20 s.
|
|
8231
8418
|
remoteInboxWatchService.start();
|
|
8232
|
-
|
|
8233
|
-
void primePendingSessionWorkers('maintenance');
|
|
8234
|
-
detachedWorkerWatch.sweep().catch((error) => {
|
|
8235
|
-
console.warn(`${runtimeLogPrefix()}WORKER DETACHED sweep failed: ${error?.message || error}`);
|
|
8236
|
-
});
|
|
8237
|
-
}, 5_000);
|
|
8238
|
-
if (typeof runtimeTimers.pendingWorkerPrime.unref === 'function') runtimeTimers.pendingWorkerPrime.unref();
|
|
8419
|
+
pendingWorkerPrimeTimer.arm();
|
|
8239
8420
|
|
|
8240
8421
|
// POST /api/heartbeat — CLI sends a ping every poll interval
|
|
8241
8422
|
|
|
@@ -8364,6 +8545,7 @@ const sshTunnelManager = createSshTunnelManager({
|
|
|
8364
8545
|
logger: console,
|
|
8365
8546
|
runtimeShutdownRef: () => runtimeShutdownStarted,
|
|
8366
8547
|
configBaseDir: __dirname,
|
|
8548
|
+
onBlockingLifted: () => { wakeSessionWorkerQueue('ssh-tunnel-connected'); },
|
|
8367
8549
|
});
|
|
8368
8550
|
const tunnelState = sshTunnelManager.state;
|
|
8369
8551
|
runtimeState.tunnelState = tunnelState;
|
|
@@ -8377,6 +8559,7 @@ const cloudflaredTunnelManager = createCloudflaredTunnelManager({
|
|
|
8377
8559
|
logger: console,
|
|
8378
8560
|
runtimeShutdownRef: () => runtimeShutdownStarted,
|
|
8379
8561
|
configBaseDir: __dirname,
|
|
8562
|
+
onBlockingLifted: () => { wakeSessionWorkerQueue('cloudflared-tunnel-connected'); },
|
|
8380
8563
|
});
|
|
8381
8564
|
runtimeState.cloudflaredTunnelState = cloudflaredTunnelManager.state;
|
|
8382
8565
|
|
|
@@ -8494,16 +8677,18 @@ function clearRuntimeTimers() {
|
|
|
8494
8677
|
try { clearTimeout(timer); } catch {}
|
|
8495
8678
|
}
|
|
8496
8679
|
subagentRecordRetention?.stop();
|
|
8680
|
+
try { workerRecycleService.stop(); } catch {}
|
|
8497
8681
|
try { taskHealthPoller.stop(); } catch {}
|
|
8498
8682
|
try { taskOutputRooms.stop(); } catch {}
|
|
8499
|
-
runtimeTimers.cliStatus = null;
|
|
8500
8683
|
runtimeTimers.ownerWatchdog = null;
|
|
8501
8684
|
runtimeTimers.staleRecovery = null;
|
|
8502
8685
|
runtimeTimers.questionExpiry = null;
|
|
8503
|
-
|
|
8504
|
-
|
|
8686
|
+
pendingWorkerPrimeTimer.stop();
|
|
8687
|
+
sharedViewerPruneTimer.stop();
|
|
8505
8688
|
runtimeTimers.restoredTaskSettle = null;
|
|
8689
|
+
runtimeTimers.archivedWorkerSweep = null;
|
|
8506
8690
|
try { relayRestartQueue.dispose(); } catch {}
|
|
8691
|
+
try { cliPresence.clear(); } catch {}
|
|
8507
8692
|
}
|
|
8508
8693
|
|
|
8509
8694
|
let runtimeShutdownPromise = null;
|
|
@@ -8714,4 +8899,16 @@ httpServer.listen(config.port, listenHost, () => {
|
|
|
8714
8899
|
// Deferred and unref'd (a few seconds behind the startup import's own CLI
|
|
8715
8900
|
// spawn); a relay without an installed Copilot CLI skips it entirely.
|
|
8716
8901
|
copilotModelDiscoveryService.scheduleStartupRefresh();
|
|
8902
|
+
// Archived conversations' workers: stopped once, 30 s in, so the workers
|
|
8903
|
+
// that reconnect after the restart and the first prime have settled. With
|
|
8904
|
+
// routing off the relay launches no workers, so there is nothing to sweep.
|
|
8905
|
+
if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) {
|
|
8906
|
+
runtimeTimers.archivedWorkerSweep = setTimeout(() => {
|
|
8907
|
+
runtimeTimers.archivedWorkerSweep = null;
|
|
8908
|
+
sweepArchivedSessionWorkersAtStart().catch((error) => {
|
|
8909
|
+
console.warn(`${runtimeLogPrefix()}WORKER RECYCLE archived sweep failed: ${error?.message || error}`);
|
|
8910
|
+
});
|
|
8911
|
+
}, 30_000);
|
|
8912
|
+
runtimeTimers.archivedWorkerSweep.unref?.();
|
|
8913
|
+
}
|
|
8717
8914
|
});
|