@oar-sh/oar 0.9.19 → 0.9.21
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 +2 -26
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
- package/CHANGELOG.md +529 -0
- package/README.md +14 -14
- package/bin/oar.js +392 -98
- package/package.json +4 -2
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
- package/server/claude-worker/claude-session-process.mjs +10 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/claude-worker/claude-turn-publisher.mjs +163 -8
- package/server/claude-worker/claude-usage-totals.mjs +58 -0
- package/server/copilot-worker/copilot-plan-board.mjs +4 -7
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
- package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/db-schema.mjs +18 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +47 -6
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/migrations/0001-add-structured-answer.mjs +5 -7
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
- package/server/migrations/0003-push-subscriptions.mjs +5 -7
- package/server/migrations/0004-steer-settle-markers.mjs +5 -7
- package/server/migrations/0005-remote-relays.mjs +5 -7
- package/server/migrations/0006-workspace-root-settings.mjs +5 -7
- package/server/migrations/0007-claude-cloud.mjs +5 -7
- package/server/migrations/0008-message-pins.mjs +5 -7
- package/server/migrations/0009-subagent-timeline.mjs +5 -7
- package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
- package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
- package/server/migrations/0013-previews.mjs +5 -7
- package/server/migrations/0014-conversation-groups.mjs +5 -7
- package/server/migrations/0015-conversation-group-position.mjs +5 -7
- package/server/migrations/0016-share-access-log.mjs +5 -7
- package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
- package/server/migrations/0018-background-task-rows.mjs +5 -7
- package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
- package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
- package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
- package/server/migrations/0022-relay-schedules.mjs +5 -7
- package/server/migrations/0023-relay-asks.mjs +5 -7
- package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
- package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
- package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
- package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
- package/server/migrations/0031-queue-provider.mjs +5 -7
- package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
- package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
- package/server/migrations/0035-conversation-plan-only.mjs +87 -0
- package/server/migrations/0036-token-usage.mjs +267 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +106 -3
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +6 -3
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +611 -70
- package/server/public/app/chat-actions-menu-state.mjs +139 -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-control-state.mjs +12 -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 +16 -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 +20 -2
- 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 +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +50 -2
- package/server/public/app/inbox-view.mjs +141 -5
- package/server/public/app/journal-view.js +311 -58
- 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/plan-board-shared.mjs +49 -0
- package/server/public/app/plan-usage-view.mjs +1 -1
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-board-inline.mjs +4 -1
- package/server/public/app/relay-board-view.js +38 -10
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +42 -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/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -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 +15 -4
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -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 +2258 -1585
- package/server/public/sw.js +8 -0
- package/server/relay-heap-guard.mjs +14 -1
- package/server/relay-self-restart.mjs +83 -2
- package/server/relay-tools.md +5 -3
- package/server/repositories/message-repository.mjs +54 -1
- package/server/repositories/remote-relay-repository.mjs +10 -0
- package/server/repositories/session-repository.mjs +25 -1
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +144 -1
- package/server/routes/relay-ask-routes.mjs +138 -8
- package/server/routes/relay-board-routes.mjs +55 -40
- package/server/routes/sessions-routes.mjs +144 -14
- package/server/server-runtime.mjs +322 -51
- package/server/services/about-service.mjs +81 -53
- package/server/services/cache-rebuild-service.mjs +7 -0
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/conversation-preferences-service.mjs +31 -0
- package/server/services/database-file.mjs +413 -0
- package/server/services/db-backup-service.mjs +809 -0
- package/server/services/deepseek-usage-import.mjs +6 -9
- package/server/services/delete-archive-service.mjs +3 -0
- package/server/services/earlier-database-service.mjs +69 -126
- package/server/services/oar-cli-helpers.mjs +214 -12
- package/server/services/oar-cli-menu.mjs +1 -0
- package/server/services/oar-state-migration-service.mjs +33 -13
- package/server/services/package-data-rescue.mjs +4 -2
- package/server/services/plan-usage-claude-api.mjs +14 -15
- package/server/services/plan-usage-contract.mjs +6 -2
- package/server/services/plan-usage-deepseek.mjs +1 -1
- package/server/services/plan-usage-tokens.mjs +248 -0
- package/server/services/push-dispatch-service.mjs +134 -9
- package/server/services/relay-cli-launcher-service.mjs +3 -0
- package/server/services/relay-event-service.mjs +30 -0
- package/server/services/relay-question-context.mjs +11 -0
- package/server/services/relay-restart-picture.mjs +67 -22
- package/server/services/remote-inbox-watch-service.mjs +204 -0
- package/server/services/remote-relay-caller-context.mjs +80 -7
- package/server/services/remote-relay-dispatcher.mjs +101 -19
- package/server/services/restart-resume-service.mjs +5 -2
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-launch-service.mjs +12 -3
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-stop-service.mjs +27 -6
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/token-usage-service.mjs +430 -0
- package/server/services/update-install-service.mjs +13 -0
- package/server/services/usage-limit-pause-service.mjs +8 -1
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/server/services/worker-scope.mjs +77 -0
- package/shared/build-id.mjs +128 -0
- package/shared/plan-board-actions.mjs +130 -0
- package/shared/plan-only.mjs +43 -0
- package/shared/relay-asks.mjs +3 -0
- package/shared/relay-events.mjs +38 -4
- package/shared/remote-relay-contract.mjs +66 -11
- package/shared/turn-token-usage.mjs +144 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -28,6 +28,8 @@ import { createDetachedWorkerWatch } from './services/detached-worker-watch-serv
|
|
|
28
28
|
import { applyCdnNoStoreHeaders } from './services/file-serving.mjs';
|
|
29
29
|
import { pickLiveTurnRowId } from './services/live-turn-picker.mjs';
|
|
30
30
|
import { buildSteerSettleFailure, steerAgentLabelForProvider } from '../shared/steer-settle-failure.mjs';
|
|
31
|
+
import { PLAN_BOARD_APPROVE_ONLY, planBoardShownActions } from '../shared/plan-board-actions.mjs';
|
|
32
|
+
import { carryPlanOnlyToCompacted, readPlanOnly } from './services/conversation-preferences-service.mjs';
|
|
31
33
|
import { sanitizeRelayQuestionContext as sanitizeRelayQuestionContextWith } from './services/relay-question-context.mjs';
|
|
32
34
|
import { isRealPathWithinRoot } from './services/workspace-symlink-guard.mjs';
|
|
33
35
|
import { applySchema } from './db-schema.mjs';
|
|
@@ -73,7 +75,11 @@ import { createDeepSeekUsageImportService } from './services/deepseek-usage-impo
|
|
|
73
75
|
import { createClaudeApiSettingsService } from './services/claude-api-settings-service.mjs';
|
|
74
76
|
import { createClaudeBillingSwitchService, normalizeClaudeBilling } from './services/claude-billing-switch-service.mjs';
|
|
75
77
|
import { createClaudeBillingSwapService } from './services/claude-billing-swap-service.mjs';
|
|
78
|
+
import { createWorkerReplacement, createWorkerReplacementFence } from './services/worker-replacement.mjs';
|
|
79
|
+
import { sessionLaunchMutex } from './services/session-launch-mutex-service.mjs';
|
|
80
|
+
import { createWorkerRecycleService, sweepArchivedWorkers } from './services/worker-recycle-service.mjs';
|
|
76
81
|
import { createClaudeApiUsageService } from './services/claude-api-usage-service.mjs';
|
|
82
|
+
import { createTokenUsageService } from './services/token-usage-service.mjs';
|
|
77
83
|
import { createClaudeApiCapWatch, createClaudeApiPlanUsage, createClaudeApiStopNoteLedger } from './services/plan-usage-claude-api.mjs';
|
|
78
84
|
import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
|
|
79
85
|
import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
|
|
@@ -101,6 +107,7 @@ import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
|
101
107
|
import { createRelayScheduleService } from './services/relay-schedule-service.mjs';
|
|
102
108
|
import { createRelayAskService } from './services/relay-ask-service.mjs';
|
|
103
109
|
import { createRelayAskLinkService } from './services/relay-ask-link-service.mjs';
|
|
110
|
+
import { createRemoteInboxWatchService } from './services/remote-inbox-watch-service.mjs';
|
|
104
111
|
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
105
112
|
import { buildClaudeAttributionSettings, normalizeClaudeAttributionMode, resolveClaudeAttributionMode, DEFAULT_CLAUDE_ATTRIBUTION_MODE } from '../shared/claude-attribution.mjs';
|
|
106
113
|
import {
|
|
@@ -115,7 +122,8 @@ import { createActiveDeviceTracker } from './services/push-active-device-service
|
|
|
115
122
|
import { registerPushRoutes } from './routes/push-routes.mjs';
|
|
116
123
|
import { registerAboutRoutes } from './routes/about-routes.mjs';
|
|
117
124
|
import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
|
|
118
|
-
import { createAboutService
|
|
125
|
+
import { createAboutService } from './services/about-service.mjs';
|
|
126
|
+
import { resolveBuildId } from '../shared/build-id.mjs';
|
|
119
127
|
import { createImageOperationService } from './services/image-operation-service.mjs';
|
|
120
128
|
import {
|
|
121
129
|
normalizeDriveAbsolutePath as _normalizeDriveAbsolutePath,
|
|
@@ -142,7 +150,10 @@ import { createCursorSessionRootResolver } from './services/cursor-session-root-
|
|
|
142
150
|
import { createGitChangesService } from './services/git-changes-service.mjs';
|
|
143
151
|
import { createRelaySingletonGuard } from './services/relay-singleton-guard.mjs';
|
|
144
152
|
import { startRelayMemoryLog } from './relay-heap-guard.mjs';
|
|
145
|
-
import { adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
|
|
153
|
+
import { TAKEOVER_PROGRESS_FILE, adoptEarlierDatabase, lostPackageDatabaseWarning, packageDatabaseEvidence } from './services/earlier-database-service.mjs';
|
|
154
|
+
import { checkDatabaseFile, resolveDatabaseFile, settleDatabaseFile } from './services/database-file.mjs';
|
|
155
|
+
import { describeBackups, describeNextBackup, maybeBackupBeforeStart, writeDbLastRun } from './services/db-backup-service.mjs';
|
|
156
|
+
import { resolveStateLayout } from './services/oar-state-layout.mjs';
|
|
146
157
|
import { ensureDataDirs, resolveDataDir, resolveUploadDir, resolveUploadReadDirs, uploadPathInDirs } from './services/data-dir-resolution.mjs';
|
|
147
158
|
import { mergeStrayMigratedUploads } from './services/oar-state-migration-service.mjs';
|
|
148
159
|
import { createRelayRestartOrchestrator } from './services/relay-restart-orchestrator-service.mjs';
|
|
@@ -219,7 +230,8 @@ import {
|
|
|
219
230
|
resolveRelayRestartMaxWaitOverrideMs,
|
|
220
231
|
RELAY_RESTART_MAX_WAIT_SETTING_KEY,
|
|
221
232
|
} from './services/relay-restart-queue.mjs';
|
|
222
|
-
import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
|
|
233
|
+
import { createRestartPictureCollector, detectRelayHosting } from './services/relay-restart-picture.mjs';
|
|
234
|
+
import { createWorkerScope } from './services/worker-scope.mjs';
|
|
223
235
|
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
224
236
|
import { createStaleTurnRecovery } from './services/stale-turn-recovery.mjs';
|
|
225
237
|
import {
|
|
@@ -290,7 +302,12 @@ const INSTALL_METHOD = fs.existsSync(path.join(PACKAGE_ROOT, '.git')) ? 'git-che
|
|
|
290
302
|
// When this relay process started, and the commit its code came from (read
|
|
291
303
|
// once: a checkout moved on later still runs the code it started with).
|
|
292
304
|
const RELAY_STARTED_AT = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
293
|
-
|
|
305
|
+
// The build id (version plus commit) names that code for the session workers:
|
|
306
|
+
// a worker that states another one started on older code. Read in the same
|
|
307
|
+
// pass as the commit so the two cannot disagree.
|
|
308
|
+
const RELAY_BUILD = resolveBuildId(PACKAGE_ROOT);
|
|
309
|
+
const BUILD_COMMIT = RELAY_BUILD.commit;
|
|
310
|
+
const BUILD_ID = RELAY_BUILD.id;
|
|
294
311
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
295
312
|
const INDEX_HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
|
|
296
313
|
const PWA_MANIFEST_PATH = path.join(PUBLIC_DIR, 'manifest.webmanifest');
|
|
@@ -320,7 +337,9 @@ const CONFIG_PATH = process.env.COPILOT_WEB_RELAY_CONFIG
|
|
|
320
337
|
// for why the config can carry it (a dev checkout pointing at a release
|
|
321
338
|
// install's data).
|
|
322
339
|
const DATA_DIR = resolveDataDir({ env: process.env, configPath: CONFIG_PATH, serverDir: __dirname });
|
|
323
|
-
|
|
340
|
+
// DB_PATH (oar.db, or the name of installs from before 0.9.20) is
|
|
341
|
+
// resolved under the lock, once an earlier database may have been brought
|
|
342
|
+
// over: see resolveDatabaseFile below.
|
|
324
343
|
const RELAY_LOCK_PATH = path.join(DATA_DIR, 'relay-server.lock');
|
|
325
344
|
// Uploads follow the data dir whenever the env var or config.json chose it.
|
|
326
345
|
const UPLOAD_DIR = resolveUploadDir({ dataDir: DATA_DIR, env: process.env, serverDir: __dirname });
|
|
@@ -1441,7 +1460,16 @@ async function refreshGrokProviderModels() {
|
|
|
1441
1460
|
}
|
|
1442
1461
|
}
|
|
1443
1462
|
|
|
1444
|
-
const DEFAULT_CONFIG = {
|
|
1463
|
+
const DEFAULT_CONFIG = {
|
|
1464
|
+
authToken: '',
|
|
1465
|
+
port: 3333,
|
|
1466
|
+
pollIntervalMs: 3000,
|
|
1467
|
+
conversationSessionMode: 'isolated',
|
|
1468
|
+
localhostOnly: true,
|
|
1469
|
+
// A copy of the database at the first start of each new version, the last
|
|
1470
|
+
// `keep` kept (db-backup-service.mjs); `dir` (absolute) moves the folder.
|
|
1471
|
+
dbBackups: { enabled: true, keep: 2 },
|
|
1472
|
+
};
|
|
1445
1473
|
// How long a turn may go without any sign of life from its worker before the
|
|
1446
1474
|
// relay assumes the worker died. This is an inactivity window, not a cap on turn
|
|
1447
1475
|
// length — see recoverStaleMessages.
|
|
@@ -1868,8 +1896,28 @@ try {
|
|
|
1868
1896
|
console.error(`[server] ${error?.message || String(error)}`);
|
|
1869
1897
|
process.exit(error?.code === 'ERELAYLOCKED' ? RELAY_STOP_EXIT_CODE : 1);
|
|
1870
1898
|
}
|
|
1871
|
-
// Before the database opens, under the lock
|
|
1872
|
-
//
|
|
1899
|
+
// Before the database opens, under the lock: both names there are settled
|
|
1900
|
+
// first (the earlier name's database, not newer, is set aside, else the
|
|
1901
|
+
// relay stops), then adoptEarlierDatabase, whose result also feeds
|
|
1902
|
+
// lostPackageDatabaseWarning.
|
|
1903
|
+
{
|
|
1904
|
+
let settled;
|
|
1905
|
+
try {
|
|
1906
|
+
settled = settleDatabaseFile({
|
|
1907
|
+
dataDir: DATA_DIR,
|
|
1908
|
+
Database,
|
|
1909
|
+
progressFile: path.join(DATA_DIR, TAKEOVER_PROGRESS_FILE),
|
|
1910
|
+
logger: console,
|
|
1911
|
+
});
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
settled = { status: 'refused', error: `[data] Not starting: the database files in ${DATA_DIR} could not be looked at (${error?.message || error}).` };
|
|
1914
|
+
}
|
|
1915
|
+
if (settled.status === 'refused') {
|
|
1916
|
+
console.error(settled.error);
|
|
1917
|
+
try { relaySingletonGuard.release(); } catch {}
|
|
1918
|
+
process.exit(RELAY_STOP_EXIT_CODE);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1873
1921
|
let earlierDatabase;
|
|
1874
1922
|
{
|
|
1875
1923
|
try {
|
|
@@ -1892,6 +1940,21 @@ let earlierDatabase;
|
|
|
1892
1940
|
process.exit(RELAY_STOP_EXIT_CODE);
|
|
1893
1941
|
}
|
|
1894
1942
|
}
|
|
1943
|
+
// Resolved after the adoption, which may have placed the database: `created`
|
|
1944
|
+
// only when neither name is there now.
|
|
1945
|
+
const databaseFile = resolveDatabaseFile(DATA_DIR);
|
|
1946
|
+
const DB_PATH = databaseFile.path;
|
|
1947
|
+
{
|
|
1948
|
+
// A dangling link, or an oar.db-wal/-shm without oar.db (a later rename to
|
|
1949
|
+
// oar.db would take its pages in), stops the start; other strays are said.
|
|
1950
|
+
const check = checkDatabaseFile(databaseFile);
|
|
1951
|
+
for (const warning of check.warnings) console.warn(warning);
|
|
1952
|
+
if (check.refusal) {
|
|
1953
|
+
console.error(check.refusal);
|
|
1954
|
+
try { relaySingletonGuard.release(); } catch {}
|
|
1955
|
+
process.exit(RELAY_STOP_EXIT_CODE);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1895
1958
|
|
|
1896
1959
|
const MAX_REQUEUE_RETRIES = Number.isFinite(Number(config.maxRequeueRetries))
|
|
1897
1960
|
? Math.max(1, Number(config.maxRequeueRetries))
|
|
@@ -1952,6 +2015,8 @@ const runtimeTimers = {
|
|
|
1952
2015
|
claudeApiCapClock: null,
|
|
1953
2016
|
memoryLog: null,
|
|
1954
2017
|
memoryWatch: null,
|
|
2018
|
+
// Once, 30 s after the relay listens: archived sessions' workers stopped.
|
|
2019
|
+
archivedWorkerSweep: null,
|
|
1955
2020
|
};
|
|
1956
2021
|
|
|
1957
2022
|
function normalizeRemotePath(value) {
|
|
@@ -2737,7 +2802,7 @@ const configuredConversationSessionMode =
|
|
|
2737
2802
|
updateModelCatalog({ models: [DEFAULT_MODEL], currentModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL, source: 'bootstrap' });
|
|
2738
2803
|
|
|
2739
2804
|
// ─── SQLite Setup ─────────────────────────────────────────────────────────────
|
|
2740
|
-
const databaseCreatedAtStart =
|
|
2805
|
+
const databaseCreatedAtStart = databaseFile.created;
|
|
2741
2806
|
const db = new Database(DB_PATH);
|
|
2742
2807
|
if (process.platform !== 'win32') {
|
|
2743
2808
|
fs.chmodSync(DB_PATH, 0o600);
|
|
@@ -2750,11 +2815,39 @@ if (process.platform !== 'win32') {
|
|
|
2750
2815
|
}
|
|
2751
2816
|
}
|
|
2752
2817
|
|
|
2818
|
+
// Where the state root keeps backups (npm), or a checkout (server/backups).
|
|
2819
|
+
const STATE_LAYOUT = resolveStateLayout({ packageRoot: REPO_ROOT });
|
|
2820
|
+
// A copy of the database before this version's migrations, when another
|
|
2821
|
+
// version (on a checkout: another commit) ran on it last; before applySchema
|
|
2822
|
+
// and before reconcileUpdateAttempt, which removes the in-app update's
|
|
2823
|
+
// record that names the version before. Never stops the start.
|
|
2824
|
+
let dbBackupAtStart = null;
|
|
2825
|
+
try {
|
|
2826
|
+
dbBackupAtStart = await maybeBackupBeforeStart({
|
|
2827
|
+
db,
|
|
2828
|
+
Database,
|
|
2829
|
+
dbPath: DB_PATH,
|
|
2830
|
+
dataDir: DATA_DIR,
|
|
2831
|
+
databaseCreated: databaseCreatedAtStart,
|
|
2832
|
+
layout: STATE_LAYOUT,
|
|
2833
|
+
serverDir: __dirname,
|
|
2834
|
+
config,
|
|
2835
|
+
runningVersion: RUNNING_VERSION,
|
|
2836
|
+
runningCommit: BUILD_COMMIT,
|
|
2837
|
+
logger: console,
|
|
2838
|
+
});
|
|
2839
|
+
} catch (error) {
|
|
2840
|
+
console.warn(`[backup] failed: ${error?.message || error}; this start goes on without a backup of the version before.`);
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2753
2843
|
// Base DDL plus the column-backfill/upgrade migrations, extracted verbatim to
|
|
2754
2844
|
// db-schema.mjs so tests can build the exact production schema. Runs the
|
|
2755
2845
|
// CREATE TABLE IF NOT EXISTS block, the FTS setup, and every ALTER TABLE
|
|
2756
2846
|
// upgrade in the original order.
|
|
2757
2847
|
applySchema(db);
|
|
2848
|
+
// A database that had no app_settings table before applySchema (a new one):
|
|
2849
|
+
// the version that runs on it now.
|
|
2850
|
+
if (dbBackupAtStart?.deferredLastRun) writeDbLastRun(db, dbBackupAtStart.deferredLastRun);
|
|
2758
2851
|
|
|
2759
2852
|
// ─── Prepared Statements ──────────────────────────────────────────────────────
|
|
2760
2853
|
// Owns its own schema (plan-usage tables) and the derived Cursor spend
|
|
@@ -3245,11 +3338,16 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
|
|
|
3245
3338
|
if (!copilotSdkEngine) return openAiEnv;
|
|
3246
3339
|
return applyCopilotSdkProviderEnvironment(openAiEnv, { enabled: true, model });
|
|
3247
3340
|
}
|
|
3341
|
+
// Under a systemd user service, session workers start in scopes of their
|
|
3342
|
+
// own (worker-scope.mjs); asked once, at the first worker start.
|
|
3343
|
+
const workerScope = createWorkerScope({ hosting: detectRelayHosting(), log: (message) => console.log(`${runtimeLogPrefix()}${message}`) });
|
|
3248
3344
|
const relayCliLauncherService = createRelayCliLauncherService({
|
|
3249
3345
|
cwd: (targetSessionId) => resolveLaunchWorkspaceRootForSession(targetSessionId),
|
|
3250
3346
|
env: sessionWorkerLaunchEnv,
|
|
3251
3347
|
log: (message) => console.log(`${runtimeLogPrefix()}${message}`),
|
|
3348
|
+
workerScope,
|
|
3252
3349
|
});
|
|
3350
|
+
|
|
3253
3351
|
async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true } = {}) {
|
|
3254
3352
|
const normalizedTargetSessionId = String(targetSessionId || '').trim();
|
|
3255
3353
|
if (!normalizedTargetSessionId) {
|
|
@@ -3281,6 +3379,7 @@ async function spawnSessionWorkerCli(targetSessionId, { allowProcessReuse = true
|
|
|
3281
3379
|
const resolvedWorkspaceRoot = resolveLaunchWorkspaceRootForSession(normalizedTargetSessionId);
|
|
3282
3380
|
const workerLaunchEnv = buildSessionWorkerLaunchEnvForSession(normalizedTargetSessionId);
|
|
3283
3381
|
const launched = await launchSessionCli({
|
|
3382
|
+
workerScope,
|
|
3284
3383
|
targetSessionId: normalizedTargetSessionId,
|
|
3285
3384
|
cwd: resolvedWorkspaceRoot,
|
|
3286
3385
|
env: workerLaunchEnv,
|
|
@@ -3359,6 +3458,7 @@ const updateCheckService = updateCheckKilled ? null : createUpdateCheckService({
|
|
|
3359
3458
|
});
|
|
3360
3459
|
const updateInstallService = createUpdateInstallService({
|
|
3361
3460
|
runningVersion: RUNNING_VERSION,
|
|
3461
|
+
describeNextBackup: () => describeNextBackup({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, dbPath: DB_PATH, config }),
|
|
3362
3462
|
installMethod: INSTALL_METHOD,
|
|
3363
3463
|
packageRoot: PACKAGE_ROOT,
|
|
3364
3464
|
readSetting: readAppSettingValue,
|
|
@@ -3676,6 +3776,7 @@ function collectRestartPicture({ action = 'restart' } = {}) {
|
|
|
3676
3776
|
restartPictureCollector = createRestartPictureCollector({
|
|
3677
3777
|
db,
|
|
3678
3778
|
backgroundWorkView,
|
|
3779
|
+
workerScopes: () => workerScope.inUse(),
|
|
3679
3780
|
listHeldTasks: () => backgroundTaskStore.listRestored().map(({ key, task }) => ({
|
|
3680
3781
|
conversationId: resolveConversationRecord({ conversationId: key, sdkSessionId: key })?.id || key,
|
|
3681
3782
|
description: task?.description || task?.taskType || '',
|
|
@@ -4055,19 +4156,28 @@ function parseBoardContext(rawContext) {
|
|
|
4055
4156
|
|
|
4056
4157
|
function formatRelayBoardRow(row) {
|
|
4057
4158
|
if (!row) return null;
|
|
4159
|
+
const boardType = String(row.board_type || '').trim().toLowerCase() || 'generic';
|
|
4160
|
+
const status = String(row.status || 'pending').trim().toLowerCase() || 'pending';
|
|
4161
|
+
const planOnly = readPlanOnly(stmts.getConvAnyStatus.get(row.conversation_id));
|
|
4058
4162
|
return {
|
|
4059
4163
|
id: row.id,
|
|
4060
4164
|
queueId: row.queue_id,
|
|
4061
4165
|
conversationId: row.conversation_id,
|
|
4062
4166
|
messageId: row.message_id,
|
|
4063
|
-
boardType
|
|
4167
|
+
boardType,
|
|
4064
4168
|
mode: normalizeRelayMode(row.relay_mode) || DEFAULT_RELAY_MODE,
|
|
4065
4169
|
title: String(row.title || '').trim(),
|
|
4066
4170
|
body: String(row.body || '').trim(),
|
|
4067
|
-
|
|
4068
|
-
|
|
4171
|
+
// A pending plan card stored before "Approve plan" existed shows it too.
|
|
4172
|
+
actions: planBoardShownActions({ boardType, status }, parseBoardActions(row.actions_json)),
|
|
4173
|
+
// On a plan-only session a pending plan card recommends "Approve plan".
|
|
4174
|
+
recommendedAction: (planOnly && status === 'pending' && boardType === 'plan_ready')
|
|
4175
|
+
? PLAN_BOARD_APPROVE_ONLY
|
|
4176
|
+
: (String(row.recommended_action || '').trim() || null),
|
|
4177
|
+
// The UI asks before an action that leaves plan mode on such a session.
|
|
4178
|
+
planOnly,
|
|
4069
4179
|
context: parseBoardContext(row.context_json),
|
|
4070
|
-
status
|
|
4180
|
+
status,
|
|
4071
4181
|
selectedAction: String(row.selected_action || '').trim() || null,
|
|
4072
4182
|
actedAt: row.acted_at || null,
|
|
4073
4183
|
createdAt: row.created_at,
|
|
@@ -5249,6 +5359,8 @@ function createCompactedConversation(sourceConversationId) {
|
|
|
5249
5359
|
// The new conversation stays in the old one's session group, and heads
|
|
5250
5360
|
// it if the old one did; the archived source stays a member.
|
|
5251
5361
|
conversationGroups.carryToCompacted(sourceConversationId, targetConversationId, now);
|
|
5362
|
+
// A plan-only conversation's lock goes along, with its saved mode.
|
|
5363
|
+
carryPlanOnlyToCompacted({ db, stmts, source, targetConversationId, updatedAt: now });
|
|
5252
5364
|
// A Claude chat keeps its billing (the subscription or the API key, a
|
|
5253
5365
|
// pending swap's included): the new chat's first worker launches on it,
|
|
5254
5366
|
// and it is never guessed again from a Claude model id. Other providers
|
|
@@ -5550,6 +5662,9 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5550
5662
|
// workers' reports (services/claude-api-usage-service.mjs); the Claude API
|
|
5551
5663
|
// card and its cap read it.
|
|
5552
5664
|
const claudeApiUsageService = createClaudeApiUsageService({ db, logger: console });
|
|
5665
|
+
// Tokens per turn and per day for every provider, booked from the
|
|
5666
|
+
// workers' reports; the Tokens sections in Check Usage and About read it.
|
|
5667
|
+
const tokenUsageService = createTokenUsageService({ db, logger: console });
|
|
5553
5668
|
// This month's spend on the key against the cap, and the Check Usage card
|
|
5554
5669
|
// (services/plan-usage-claude-api.mjs), over the same local days.
|
|
5555
5670
|
const claudeApiPlanUsage = createClaudeApiPlanUsage({
|
|
@@ -6446,6 +6561,23 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
6446
6561
|
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
6447
6562
|
};
|
|
6448
6563
|
}
|
|
6564
|
+
// The process asking is being replaced (the worker recycle passed its holds
|
|
6565
|
+
// for it): no work, as for a killed session; the worker stays ready, and
|
|
6566
|
+
// what waits goes to the new one. Unlike the killed marker above, this
|
|
6567
|
+
// survives the marker's grace and every clearRestartSchedule.
|
|
6568
|
+
if (workerReplacementFence.isHeld(requesterSessionId, pid)) {
|
|
6569
|
+
return {
|
|
6570
|
+
message: null,
|
|
6571
|
+
routing: {
|
|
6572
|
+
enabled: true,
|
|
6573
|
+
requesterSessionId,
|
|
6574
|
+
blockedReason: 'worker-replacement',
|
|
6575
|
+
lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
|
|
6576
|
+
fallbackRestart: null,
|
|
6577
|
+
},
|
|
6578
|
+
restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
|
|
6579
|
+
};
|
|
6580
|
+
}
|
|
6449
6581
|
sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
|
|
6450
6582
|
if (runtimeState.relayPaused) {
|
|
6451
6583
|
return { message: null, paused: true, reason: 'relay_paused' };
|
|
@@ -6525,6 +6657,9 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
6525
6657
|
workerKind: workerKind || sessionWorkerRegistry?.getWorker?.(requesterSessionId)?.kind || null,
|
|
6526
6658
|
claudeApiClaimCheck,
|
|
6527
6659
|
onBillingFence: handleClaudeBillingFence,
|
|
6660
|
+
// The fence check above, asked again before every claim: ensureWorker
|
|
6661
|
+
// and a transient error's backoff both wait.
|
|
6662
|
+
isReplacementFenced: () => workerReplacementFence.isHeld(requesterSessionId, pid),
|
|
6528
6663
|
});
|
|
6529
6664
|
if (!dequeueResult?.message) {
|
|
6530
6665
|
return {
|
|
@@ -6838,48 +6973,38 @@ async function sweepDeadWorkerProcessingRowsOnce() {
|
|
|
6838
6973
|
// claude-billing-swap-service.mjs): a Claude chat's next row of the other
|
|
6839
6974
|
// billing is refused to the worker it has, which is replaced, once idle, by
|
|
6840
6975
|
// one of the row's kind. The old one is stopped like the kill route does, and
|
|
6841
|
-
// the new one launched only once every process of the session is gone
|
|
6976
|
+
// the new one launched only once every process of the session is gone
|
|
6977
|
+
// (services/worker-replacement.mjs, which the worker recycle below shares).
|
|
6978
|
+
// The two get one instance each so a failed stop's warning names the caller.
|
|
6979
|
+
// They share one replacement fence, the one the deliveries check (the socket
|
|
6980
|
+
// delivery above and /api/pending): a process being replaced is handed no
|
|
6981
|
+
// work. The worker recycle raises it; the billing swap does not, since its
|
|
6982
|
+
// own delivery fence already keeps the other billing's rows from the old
|
|
6983
|
+
// worker.
|
|
6984
|
+
const workerReplacementFence = createWorkerReplacementFence();
|
|
6985
|
+
function createRuntimeWorkerReplacement(tag) {
|
|
6986
|
+
return createWorkerReplacement({
|
|
6987
|
+
registry: sessionWorkerRegistry,
|
|
6988
|
+
supervisor: sessionWorkerSupervisor,
|
|
6989
|
+
processInspector: sessionWorkerProcessInspector,
|
|
6990
|
+
stopSessionWorkerProcesses,
|
|
6991
|
+
isProcessAlive,
|
|
6992
|
+
killTmuxSession,
|
|
6993
|
+
logger: console,
|
|
6994
|
+
logPrefix: () => `${runtimeLogPrefix()}${tag} `,
|
|
6995
|
+
fence: workerReplacementFence,
|
|
6996
|
+
});
|
|
6997
|
+
}
|
|
6998
|
+
// A stop that fails backs off (the swap service retries it) and clears the
|
|
6999
|
+
// killed marker, so the session's worker, alive or relaunched, keeps asking
|
|
7000
|
+
// for work meanwhile. The fence still keeps the other billing's rows from it.
|
|
7001
|
+
const billingSwapWorkerReplacement = createRuntimeWorkerReplacement('BILLING SWAP');
|
|
6842
7002
|
const claudeBillingSwapService = createClaudeBillingSwapService({
|
|
6843
7003
|
getWorker: (sid) => sessionWorkerRegistry?.getWorker?.(sid) || null,
|
|
6844
7004
|
// A row the session claimed and has not finished: never stopped under it.
|
|
6845
7005
|
hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
|
|
6846
|
-
stopWorker:
|
|
6847
|
-
|
|
6848
|
-
// Nothing may start the session while it is being stopped: a launch then
|
|
6849
|
-
// would adopt the old process, kind and all.
|
|
6850
|
-
sessionWorkerSupervisor?.markKilled?.(sid);
|
|
6851
|
-
// A stop that fails backs off (the swap service retries it): the killed
|
|
6852
|
-
// marker goes with it, so the session's worker, alive or relaunched,
|
|
6853
|
-
// keeps asking for work meanwhile. The fence still keeps the other
|
|
6854
|
-
// billing's rows from it.
|
|
6855
|
-
const backOff = (error, remainingPids = []) => {
|
|
6856
|
-
sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
6857
|
-
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`);
|
|
6858
|
-
return { ok: false, error, remainingPids };
|
|
6859
|
-
};
|
|
6860
|
-
let stopped = null;
|
|
6861
|
-
try {
|
|
6862
|
-
await sessionWorkerSupervisor?.cancelPendingStart?.(sid, { wait: true });
|
|
6863
|
-
stopped = await stopSessionWorkerProcesses({
|
|
6864
|
-
sdkSessionId: sid,
|
|
6865
|
-
worker,
|
|
6866
|
-
processInspector: sessionWorkerProcessInspector,
|
|
6867
|
-
isPidAliveImpl: isProcessAlive,
|
|
6868
|
-
killTmuxSessionImpl: killTmuxSession,
|
|
6869
|
-
});
|
|
6870
|
-
} catch (error) {
|
|
6871
|
-
return backOff(String(error?.message || error || 'worker-stop-failed'));
|
|
6872
|
-
}
|
|
6873
|
-
if (!stopped.ok) return backOff(stopped.error || 'worker-survived-stop', stopped.remainingPids || []);
|
|
6874
|
-
sessionWorkerRegistry?.removeWorker?.(sid);
|
|
6875
|
-
sessionWorkerSupervisor?.resetHealth?.(sid, { clearFailureCount: false });
|
|
6876
|
-
return { ok: true };
|
|
6877
|
-
},
|
|
6878
|
-
launchWorker: async (sid) => {
|
|
6879
|
-
sessionWorkerSupervisor?.clearRestartSchedule?.(sid, { resetKilledMarker: true });
|
|
6880
|
-
const launched = await sessionWorkerSupervisor?.ensureWorker?.(sid, { allowProcessReuse: false });
|
|
6881
|
-
return { ok: launched?.ok === true, error: launched?.error || null };
|
|
6882
|
-
},
|
|
7006
|
+
stopWorker: billingSwapWorkerReplacement.stopWorker,
|
|
7007
|
+
launchWorker: billingSwapWorkerReplacement.launchWorker,
|
|
6883
7008
|
onStuck: ({ conversationId, rowBilling, attempts, reason }) => {
|
|
6884
7009
|
if (!conversationId) return;
|
|
6885
7010
|
const at = new Date().toISOString();
|
|
@@ -6911,6 +7036,95 @@ const claudeBillingSwapService = createClaudeBillingSwapService({
|
|
|
6911
7036
|
logger: console,
|
|
6912
7037
|
});
|
|
6913
7038
|
|
|
7039
|
+
// Whether a worker's session is one the relay keeps no worker for: its
|
|
7040
|
+
// conversation is archived (or deleted), or there is no conversation behind
|
|
7041
|
+
// the session at all. A conversation in use found either way (the runtime
|
|
7042
|
+
// session's, or the one naming the session id) keeps its worker: a session
|
|
7043
|
+
// is never stopped for good on a missing link alone.
|
|
7044
|
+
function isWorkerSessionArchived(sid) {
|
|
7045
|
+
const runtime = stmts.getRuntimeSessionBySdkSessionId?.get?.(sid) || null;
|
|
7046
|
+
const viaRuntime = runtime?.conversation_id ? (stmts.getConvAnyStatus?.get?.(runtime.conversation_id) || null) : null;
|
|
7047
|
+
const viaSessionId = stmts.getConvBySdkSessionId?.get?.(sid) || null;
|
|
7048
|
+
const inUse = (conversation) => Boolean(conversation)
|
|
7049
|
+
&& Number(conversation.archived || 0) !== 1
|
|
7050
|
+
&& String(conversation.status || '').trim().toLowerCase() !== 'deleted';
|
|
7051
|
+
return !inUse(viaRuntime) && !inUse(viaSessionId);
|
|
7052
|
+
}
|
|
7053
|
+
|
|
7054
|
+
// The worker recycle (services/worker-recycle-service.mjs): a worker that
|
|
7055
|
+
// runs older code than this relay (another build id, or none stated) is
|
|
7056
|
+
// replaced between turns by one on this relay's code; an archived session's
|
|
7057
|
+
// is stopped for good. Its own replacement instance, so a failed stop says
|
|
7058
|
+
// WORKER RECYCLE rather than BILLING SWAP.
|
|
7059
|
+
const recycleWorkerReplacement = createRuntimeWorkerReplacement('WORKER RECYCLE');
|
|
7060
|
+
const workerRecycleService = createWorkerRecycleService({
|
|
7061
|
+
relayBuildId: BUILD_ID,
|
|
7062
|
+
listWorkers: () => sessionWorkerRegistry.listWorkers(),
|
|
7063
|
+
getWorker: (sid) => sessionWorkerRegistry.getWorker(sid),
|
|
7064
|
+
// A row the session claimed and has not finished: never stopped under it.
|
|
7065
|
+
hasRunningTurn: (sid) => (stmts.listProcessingRowsForOwner?.all?.(sid) || []).length > 0,
|
|
7066
|
+
// From the supervisor's lifecycle, not its firstObservedHeartbeatAt: that
|
|
7067
|
+
// is set only for a worker it launched itself, and the workers that
|
|
7068
|
+
// reconnect after a relay restart (the ones most likely stale) never get it.
|
|
7069
|
+
hasHeartbeated: (sid) => {
|
|
7070
|
+
const lc = sessionWorkerSupervisor.getLifecycleState(sid);
|
|
7071
|
+
return !!lc?.lastHeartbeatAt && !lc?.awaitingHeartbeat;
|
|
7072
|
+
},
|
|
7073
|
+
isArchived: (sid) => isWorkerSessionArchived(sid),
|
|
7074
|
+
// The session's last delivery or turn end, for a worker that does not say
|
|
7075
|
+
// whether its CLI child runs (message-repository getOwnerLastActivityAt).
|
|
7076
|
+
quietSince: (sid) => {
|
|
7077
|
+
const at = Date.parse(stmts.getOwnerLastActivityAt?.get?.(sid)?.last_activity_at || '');
|
|
7078
|
+
return Number.isFinite(at) ? at : null;
|
|
7079
|
+
},
|
|
7080
|
+
stopWorker: recycleWorkerReplacement.stopWorker,
|
|
7081
|
+
launchWorker: recycleWorkerReplacement.launchWorker,
|
|
7082
|
+
fence: recycleWorkerReplacement.fence,
|
|
7083
|
+
// The stop and the launch under the per-session launch mutex the Change
|
|
7084
|
+
// CWD relaunch and the launch routes take (sessions-routes.mjs).
|
|
7085
|
+
runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
|
|
7086
|
+
logger: console,
|
|
7087
|
+
});
|
|
7088
|
+
|
|
7089
|
+
// Once at start, after the restart's own worker reconnects settled: what
|
|
7090
|
+
// still runs for an archived conversation is stopped and stays stopped.
|
|
7091
|
+
// Through the recycle's replacement, one session at a time; never throws.
|
|
7092
|
+
async function sweepArchivedSessionWorkersAtStart() {
|
|
7093
|
+
if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED !== true) return null;
|
|
7094
|
+
if (runtimeShutdownStarted) return null;
|
|
7095
|
+
// The first lookup reads the process list afresh; the rest share it for the
|
|
7096
|
+
// inspector's cache moment, so a long archive costs one read, not one each.
|
|
7097
|
+
let freshRead = true;
|
|
7098
|
+
return sweepArchivedWorkers({
|
|
7099
|
+
listArchivedSessionIds: () => (stmts.listArchivedRuntimeSessionIds?.all?.() || []).map((row) => row?.sdk_session_id),
|
|
7100
|
+
hasLiveProcess: async (sid) => {
|
|
7101
|
+
if (runtimeShutdownStarted) return false;
|
|
7102
|
+
const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
|
|
7103
|
+
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return true;
|
|
7104
|
+
const fresh = freshRead;
|
|
7105
|
+
freshRead = false;
|
|
7106
|
+
return Boolean(await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid, { fresh }));
|
|
7107
|
+
},
|
|
7108
|
+
isArchived: (sid) => isWorkerSessionArchived(sid),
|
|
7109
|
+
// The process the fence keeps deliveries from during the stop: the
|
|
7110
|
+
// registry's live pid, else the one the process list shows, else none
|
|
7111
|
+
// (every process of the session is fenced then).
|
|
7112
|
+
workerPid: async (sid) => {
|
|
7113
|
+
const pid = Number(sessionWorkerRegistry?.getWorker?.(sid)?.pid);
|
|
7114
|
+
if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) return pid;
|
|
7115
|
+
const found = await sessionWorkerProcessInspector?.findProcessForSessionAsync?.(sid);
|
|
7116
|
+
return Number(found?.processId) || null;
|
|
7117
|
+
},
|
|
7118
|
+
stopWorker: recycleWorkerReplacement.stopWorker,
|
|
7119
|
+
// Guarded as the recycle's archived stop is: a message that unarchives
|
|
7120
|
+
// the session meanwhile must not start a turn on the process being
|
|
7121
|
+
// stopped.
|
|
7122
|
+
fence: workerReplacementFence,
|
|
7123
|
+
runExclusive: (sid, fn) => sessionLaunchMutex.runExclusive(sid, fn),
|
|
7124
|
+
logger: console,
|
|
7125
|
+
});
|
|
7126
|
+
}
|
|
7127
|
+
|
|
6914
7128
|
// The Claude API monthly cap (plan decision 12): `{ reached, error? }`. The
|
|
6915
7129
|
// one seam the claim check below, the billing switch, an agent's send and a
|
|
6916
7130
|
// new Claude API chat ask: this month's priced estimate in
|
|
@@ -7083,6 +7297,16 @@ const sessionWorkerWebSocketService = createSessionWorkerWebSocketService({
|
|
|
7083
7297
|
const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
|
|
7084
7298
|
if (worker && worker.kind !== kind) sessionWorkerRegistry?.upsertWorker?.({ ...worker, kind });
|
|
7085
7299
|
},
|
|
7300
|
+
// The build id a worker says it started on: the registry keeps it so a
|
|
7301
|
+
// worker on older code can be told from one on the relay's.
|
|
7302
|
+
onWorkerBuildId: (sessionId, buildId) => {
|
|
7303
|
+
const sid = String(sessionId || '').trim();
|
|
7304
|
+
if (!sid) return;
|
|
7305
|
+
const worker = sessionWorkerRegistry?.getWorker?.(sid) || null;
|
|
7306
|
+
if (worker && worker.buildId !== buildId) sessionWorkerRegistry?.upsertWorker?.({ ...worker, buildId });
|
|
7307
|
+
},
|
|
7308
|
+
// Sent in server.hello, so a worker on other code can say so in its log.
|
|
7309
|
+
buildId: BUILD_ID,
|
|
7086
7310
|
isWorkerProcessAlive: (pid) => isProcessAlive(Number(pid)),
|
|
7087
7311
|
pathPrefix: remotePath,
|
|
7088
7312
|
pollIntervalMs: 500,
|
|
@@ -7094,6 +7318,10 @@ runtimeTimers.deadWorkerSweep = setInterval(() => {
|
|
|
7094
7318
|
});
|
|
7095
7319
|
}, 30_000);
|
|
7096
7320
|
if (typeof runtimeTimers.deadWorkerSweep.unref === 'function') runtimeTimers.deadWorkerSweep.unref();
|
|
7321
|
+
// Workers on older code are recycled only where the relay launches them;
|
|
7322
|
+
// with routing off the service never starts. Its timer is its own (unref'd)
|
|
7323
|
+
// and stops with the runtime timers.
|
|
7324
|
+
if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) workerRecycleService.start();
|
|
7097
7325
|
const tmuxInspectorAccessPolicy = createTmuxInspectorAccessPolicy({
|
|
7098
7326
|
sessionWorkerRegistry,
|
|
7099
7327
|
sessionWorkerSupervisor,
|
|
@@ -7253,6 +7481,13 @@ let relayPaused = false;
|
|
|
7253
7481
|
const relayBridgeOwnerService = createRelayBridgeOwnerService();
|
|
7254
7482
|
|
|
7255
7483
|
const runtimeState = {
|
|
7484
|
+
// The relay's own build id, read once at start; null when package.json
|
|
7485
|
+
// names no version.
|
|
7486
|
+
buildId: BUILD_ID,
|
|
7487
|
+
// The worker recycle's status() (the relay's build id, the stale count,
|
|
7488
|
+
// the recycles in flight and done, who waits on what): a function, so a
|
|
7489
|
+
// route reads it when asked and needs no wiring of its own.
|
|
7490
|
+
workerRecycle: () => workerRecycleService.status(),
|
|
7256
7491
|
get cliOnline() { return cliOnline; },
|
|
7257
7492
|
set cliOnline(value) { cliOnline = value; },
|
|
7258
7493
|
get relayPaused() { return relayPaused; },
|
|
@@ -7362,6 +7597,16 @@ const relayAskLinkService = createRelayAskLinkService({
|
|
|
7362
7597
|
client: remoteRelayClient,
|
|
7363
7598
|
logger: console,
|
|
7364
7599
|
});
|
|
7600
|
+
// Paired relays' cards a turn waits on: the badge here stays current, and
|
|
7601
|
+
// one relay pushes a card its own relay reached no device for.
|
|
7602
|
+
const remoteInboxWatchService = createRemoteInboxWatchService({
|
|
7603
|
+
registry: remoteRelayRegistry,
|
|
7604
|
+
client: remoteRelayClient,
|
|
7605
|
+
push: pushDispatchService,
|
|
7606
|
+
hasSockets: () => io.of('/').sockets.size > 0,
|
|
7607
|
+
emit: (event) => io.emit(event),
|
|
7608
|
+
logger: console,
|
|
7609
|
+
});
|
|
7365
7610
|
const remoteRelayPairing = createRemoteRelayPairing({
|
|
7366
7611
|
registry: remoteRelayRegistry,
|
|
7367
7612
|
client: remoteRelayClient,
|
|
@@ -7579,6 +7824,8 @@ const sharedRouteDeps = {
|
|
|
7579
7824
|
sessionWorkerSupervisor,
|
|
7580
7825
|
sessionWorkerRegistry,
|
|
7581
7826
|
sessionWorkerProcessInspector,
|
|
7827
|
+
// /api/pending refuses work to a process the worker recycle is replacing.
|
|
7828
|
+
workerReplacementFence,
|
|
7582
7829
|
buildContextResponseText,
|
|
7583
7830
|
readContextFromSessionEvents,
|
|
7584
7831
|
sessionHistoryRefreshService,
|
|
@@ -7647,6 +7894,7 @@ const sharedRouteDeps = {
|
|
|
7647
7894
|
usageLimitPauseService,
|
|
7648
7895
|
claudeBillingSwitchService,
|
|
7649
7896
|
claudeApiUsageService,
|
|
7897
|
+
tokenUsageService,
|
|
7650
7898
|
claudeApiClaimCheck,
|
|
7651
7899
|
onClaudeBillingFence: handleClaudeBillingFence,
|
|
7652
7900
|
claudeApiPlanUsage,
|
|
@@ -7719,11 +7967,13 @@ registerRelayAskRoutes(app, {
|
|
|
7719
7967
|
db,
|
|
7720
7968
|
stmts,
|
|
7721
7969
|
formatQuestionRow,
|
|
7970
|
+
formatRelayBoardRow,
|
|
7722
7971
|
relayAskService,
|
|
7723
7972
|
relayAskLinkService,
|
|
7724
7973
|
remoteRelayInbound,
|
|
7725
7974
|
remoteRelayRegistry,
|
|
7726
7975
|
remoteRelayClient,
|
|
7976
|
+
questionNotice: (questionId) => pushDispatchService.questionNotice(questionId),
|
|
7727
7977
|
});
|
|
7728
7978
|
registerRelayRestartRoutes(app, {
|
|
7729
7979
|
auth,
|
|
@@ -7742,6 +7992,9 @@ registerAboutRoutes(app, {
|
|
|
7742
7992
|
dbPath: DB_PATH,
|
|
7743
7993
|
version: RUNNING_VERSION,
|
|
7744
7994
|
commit: BUILD_COMMIT,
|
|
7995
|
+
// The workers' build ids are counted against this one (the About modal's
|
|
7996
|
+
// "Workers: 14, 3 on an older build").
|
|
7997
|
+
buildId: BUILD_ID,
|
|
7745
7998
|
installMethod: INSTALL_METHOD,
|
|
7746
7999
|
startedAt: RELAY_STARTED_AT,
|
|
7747
8000
|
packageRoot: PACKAGE_ROOT,
|
|
@@ -7758,6 +8011,7 @@ registerAboutRoutes(app, {
|
|
|
7758
8011
|
isRelayCliOnline: () => runtimeState.cliOnline === true,
|
|
7759
8012
|
listWorkers: () => sessionWorkerRegistry.listWorkers(),
|
|
7760
8013
|
getUpdateState: () => buildUpdateStatePayload(),
|
|
8014
|
+
describeBackups: () => describeBackups({ layout: STATE_LAYOUT, serverDir: __dirname, dataDir: DATA_DIR, config, sizes: false }),
|
|
7761
8015
|
}),
|
|
7762
8016
|
});
|
|
7763
8017
|
function markCliOffline(reason = 'offline', { clearOwner = true } = {}) {
|
|
@@ -8109,6 +8363,8 @@ relayEventLinkService.start();
|
|
|
8109
8363
|
relayScheduleService.start();
|
|
8110
8364
|
// Asks on paired relays still open there: look for their answers again.
|
|
8111
8365
|
relayAskLinkService.start();
|
|
8366
|
+
// Paired relays' Inboxes, every ~20 s.
|
|
8367
|
+
remoteInboxWatchService.start();
|
|
8112
8368
|
runtimeTimers.pendingWorkerPrime = setInterval(() => {
|
|
8113
8369
|
void primePendingSessionWorkers('maintenance');
|
|
8114
8370
|
detachedWorkerWatch.sweep().catch((error) => {
|
|
@@ -8374,6 +8630,7 @@ function clearRuntimeTimers() {
|
|
|
8374
8630
|
try { clearTimeout(timer); } catch {}
|
|
8375
8631
|
}
|
|
8376
8632
|
subagentRecordRetention?.stop();
|
|
8633
|
+
try { workerRecycleService.stop(); } catch {}
|
|
8377
8634
|
try { taskHealthPoller.stop(); } catch {}
|
|
8378
8635
|
try { taskOutputRooms.stop(); } catch {}
|
|
8379
8636
|
runtimeTimers.cliStatus = null;
|
|
@@ -8383,6 +8640,7 @@ function clearRuntimeTimers() {
|
|
|
8383
8640
|
runtimeTimers.pendingWorkerPrime = null;
|
|
8384
8641
|
runtimeTimers.sharedViewerPrune = null;
|
|
8385
8642
|
runtimeTimers.restoredTaskSettle = null;
|
|
8643
|
+
runtimeTimers.archivedWorkerSweep = null;
|
|
8386
8644
|
try { relayRestartQueue.dispose(); } catch {}
|
|
8387
8645
|
}
|
|
8388
8646
|
|
|
@@ -8431,6 +8689,7 @@ function shutdownRuntime(reason = 'unknown', { exitCode = 0 } = {}) {
|
|
|
8431
8689
|
try { relayEventLinkService.stop(); } catch {}
|
|
8432
8690
|
try { relayScheduleService.stop(); } catch {}
|
|
8433
8691
|
try { relayAskLinkService.stop(); } catch {}
|
|
8692
|
+
try { remoteInboxWatchService.stop(); } catch {}
|
|
8434
8693
|
tmuxInspectorSocketService.stop();
|
|
8435
8694
|
stopWorkspaceFileWatcher();
|
|
8436
8695
|
sshTunnelManager.stop();
|
|
@@ -8593,4 +8852,16 @@ httpServer.listen(config.port, listenHost, () => {
|
|
|
8593
8852
|
// Deferred and unref'd (a few seconds behind the startup import's own CLI
|
|
8594
8853
|
// spawn); a relay without an installed Copilot CLI skips it entirely.
|
|
8595
8854
|
copilotModelDiscoveryService.scheduleStartupRefresh();
|
|
8855
|
+
// Archived conversations' workers: stopped once, 30 s in, so the workers
|
|
8856
|
+
// that reconnect after the restart and the first prime have settled. With
|
|
8857
|
+
// routing off the relay launches no workers, so there is nothing to sweep.
|
|
8858
|
+
if (featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true) {
|
|
8859
|
+
runtimeTimers.archivedWorkerSweep = setTimeout(() => {
|
|
8860
|
+
runtimeTimers.archivedWorkerSweep = null;
|
|
8861
|
+
sweepArchivedSessionWorkersAtStart().catch((error) => {
|
|
8862
|
+
console.warn(`${runtimeLogPrefix()}WORKER RECYCLE archived sweep failed: ${error?.message || error}`);
|
|
8863
|
+
});
|
|
8864
|
+
}, 30_000);
|
|
8865
|
+
runtimeTimers.archivedWorkerSweep.unref?.();
|
|
8866
|
+
}
|
|
8596
8867
|
});
|