@oar-sh/oar 0.9.12 → 0.9.13
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 +206 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/README.md +186 -11
- package/server/claude-worker/claude-session-process.mjs +53 -2
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +57 -3
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +67 -0
- package/server/relay-tools.md +5 -0
- package/server/repositories/message-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +155 -27
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +75 -3
- package/server/server-runtime.mjs +192 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +187 -19
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-service.mjs +217 -18
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +15 -8
- package/shared/task-output-text.mjs +196 -83
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -75,6 +75,13 @@ import { createHostSuspendService } from './services/host-suspend-service.mjs';
|
|
|
75
75
|
import { USAGE_LIMIT_ACCOUNT_SOCKET_EVENT, createUsageLimitPauseService } from './services/usage-limit-pause-service.mjs';
|
|
76
76
|
import { createSessionActivityTracker, resolveBootReportingWindowMs } from './services/session-activity-tracker.mjs';
|
|
77
77
|
import { createBackgroundTaskStore } from './services/background-task-store.mjs';
|
|
78
|
+
import { createSqliteBackgroundTaskPersistence } from './services/background-task-persistence.mjs';
|
|
79
|
+
import {
|
|
80
|
+
RESTART_RESUME_SETTING_KEY,
|
|
81
|
+
createRestartResumeService,
|
|
82
|
+
parseRestartResumeUpdate,
|
|
83
|
+
readRestartResumeSetting,
|
|
84
|
+
} from './services/restart-resume-service.mjs';
|
|
78
85
|
import { createBackgroundWorkView } from './services/background-work-view.mjs';
|
|
79
86
|
import { createRelayEventService } from './services/relay-event-service.mjs';
|
|
80
87
|
import { createRelayEventLinkService } from './services/relay-event-link-service.mjs';
|
|
@@ -90,6 +97,7 @@ import { runHostSuspendToRam } from './services/host-suspend-command.mjs';
|
|
|
90
97
|
import { createActiveDeviceTracker } from './services/push-active-device-service.mjs';
|
|
91
98
|
import { registerPushRoutes } from './routes/push-routes.mjs';
|
|
92
99
|
import { registerAboutRoutes } from './routes/about-routes.mjs';
|
|
100
|
+
import { registerRelayRestartRoutes } from './routes/relay-restart-routes.mjs';
|
|
93
101
|
import { createAboutService, resolveBuildCommit } from './services/about-service.mjs';
|
|
94
102
|
import { createImageOperationService } from './services/image-operation-service.mjs';
|
|
95
103
|
import {
|
|
@@ -183,7 +191,15 @@ import {
|
|
|
183
191
|
parseSharePathPrivacyUpdate,
|
|
184
192
|
readSharePathPrivacySetting,
|
|
185
193
|
} from '../shared/share-path-privacy.mjs';
|
|
186
|
-
import { isRelayShutdownQueued,
|
|
194
|
+
import { isRelayShutdownQueued, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
|
|
195
|
+
import {
|
|
196
|
+
createRelayRestartQueue,
|
|
197
|
+
parseRelayRestartMaxWaitUpdate,
|
|
198
|
+
readRelayRestartMaxWaitSetting,
|
|
199
|
+
resolveRelayRestartMaxWaitOverrideMs,
|
|
200
|
+
RELAY_RESTART_MAX_WAIT_SETTING_KEY,
|
|
201
|
+
} from './services/relay-restart-queue.mjs';
|
|
202
|
+
import { createRestartPictureCollector } from './services/relay-restart-picture.mjs';
|
|
187
203
|
import { createStaleRecoveryClockGuard } from './services/stale-recovery-clock-guard.mjs';
|
|
188
204
|
import {
|
|
189
205
|
STALE_SUBAGENT_RUN_BOOT_DELAY_MS,
|
|
@@ -1877,7 +1893,7 @@ const runtimeTimers = {
|
|
|
1877
1893
|
questionExpiry: null,
|
|
1878
1894
|
pendingWorkerPrime: null,
|
|
1879
1895
|
sharedViewerPrune: null,
|
|
1880
|
-
|
|
1896
|
+
restoredTaskSettle: null,
|
|
1881
1897
|
};
|
|
1882
1898
|
|
|
1883
1899
|
function normalizeRemotePath(value) {
|
|
@@ -2202,6 +2218,21 @@ function setTaskStallMinutes(value) {
|
|
|
2202
2218
|
return { ok: true, minutes: parsed.minutes };
|
|
2203
2219
|
}
|
|
2204
2220
|
|
|
2221
|
+
// "After a relay restart, tell interrupted sessions to carry on" (off by default).
|
|
2222
|
+
function getRestartResumeEnabled() {
|
|
2223
|
+
return readRestartResumeSetting(readAppSettingValue(RESTART_RESUME_SETTING_KEY));
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function setRestartResumeEnabled(value) {
|
|
2227
|
+
if (typeof stmts?.upsertAppSetting?.run !== 'function') {
|
|
2228
|
+
return { ok: false, error: 'Restart settings are unavailable' };
|
|
2229
|
+
}
|
|
2230
|
+
const parsed = parseRestartResumeUpdate(value);
|
|
2231
|
+
if (!parsed.ok) return parsed;
|
|
2232
|
+
stmts.upsertAppSetting.run(RESTART_RESUME_SETTING_KEY, parsed.enabled ? '1' : '0', new Date().toISOString());
|
|
2233
|
+
return { ok: true, enabled: parsed.enabled };
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2205
2236
|
function resolveDefaultSessionWorkspaceRootState() {
|
|
2206
2237
|
return resolveDefaultSessionWorkspaceRootStateFromService({
|
|
2207
2238
|
storedPath: readAppSettingValue(DEFAULT_SESSION_WORKSPACE_ROOT_KEY),
|
|
@@ -3451,140 +3482,87 @@ function attachTtyConsoleRuntimeTitle(runtime) {
|
|
|
3451
3482
|
}
|
|
3452
3483
|
attachTtyConsoleRuntimeTitle(ttyConsoleRuntime);
|
|
3453
3484
|
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3485
|
+
// ─── Queued relay restart / shutdown ─────────────────────────────────────────
|
|
3486
|
+
// A request waits until no turn is processing and holds new turns meanwhile
|
|
3487
|
+
// (services/relay-shutdown-gate.mjs); past the maximum wait it asks instead
|
|
3488
|
+
// of acting (services/relay-restart-queue.mjs). Every client shows a banner
|
|
3489
|
+
// with what it waits for, from `relay_shutdown_state` (sent on each change
|
|
3490
|
+
// and on connect).
|
|
3491
|
+
const relayRestartMaxWaitOverrideMs = resolveRelayRestartMaxWaitOverrideMs(process.env);
|
|
3492
|
+
if (relayRestartMaxWaitOverrideMs !== null) {
|
|
3493
|
+
console.log(`${runtimeLogPrefix()}Relay restart max wait pinned to ${relayRestartMaxWaitOverrideMs} ms (OAR_RELAY_RESTART_MAX_WAIT_MS)`);
|
|
3494
|
+
}
|
|
3495
|
+
function getRelayRestartMaxWaitMinutes() {
|
|
3496
|
+
return readRelayRestartMaxWaitSetting(readAppSettingValue(RELAY_RESTART_MAX_WAIT_SETTING_KEY));
|
|
3497
|
+
}
|
|
3498
|
+
function setRelayRestartMaxWaitMinutes(value) {
|
|
3499
|
+
if (!stmts.upsertAppSetting) return { ok: false, error: 'Relay restart settings are unavailable' };
|
|
3500
|
+
const parsed = parseRelayRestartMaxWaitUpdate(value);
|
|
3501
|
+
if (!parsed.ok) return parsed;
|
|
3502
|
+
stmts.upsertAppSetting.run(RELAY_RESTART_MAX_WAIT_SETTING_KEY, String(parsed.minutes), new Date().toISOString());
|
|
3503
|
+
// A queued request measures its current period against the new value.
|
|
3504
|
+
relayRestartQueue.refresh();
|
|
3505
|
+
return { ok: true, maxWaitMinutes: parsed.minutes };
|
|
3506
|
+
}
|
|
3507
|
+
let restartPictureCollector = null;
|
|
3508
|
+
// What a restart would wait for, hold and leave running (the confirmation
|
|
3509
|
+
// asks before anything is queued). Built on first use: the background-work
|
|
3510
|
+
// view is set up further down.
|
|
3511
|
+
function collectRestartPicture({ action = 'restart' } = {}) {
|
|
3512
|
+
if (!restartPictureCollector) {
|
|
3513
|
+
restartPictureCollector = createRestartPictureCollector({
|
|
3514
|
+
db,
|
|
3515
|
+
backgroundWorkView,
|
|
3516
|
+
listHeldTasks: () => backgroundTaskStore.listRestored().map(({ key, task }) => ({
|
|
3517
|
+
conversationId: resolveConversationRecord({ conversationId: key, sdkSessionId: key })?.id || key,
|
|
3518
|
+
description: task?.description || task?.taskType || '',
|
|
3519
|
+
})),
|
|
3520
|
+
providerOf: (conversationId) => stmts.getRuntimeSessionByConversation?.get?.(conversationId)?.provider_type,
|
|
3521
|
+
titleOf: (conversationId) => hostActivityTitle(conversationId),
|
|
3522
|
+
});
|
|
3473
3523
|
}
|
|
3474
|
-
return {
|
|
3475
|
-
status: runtimeShutdownStarted ? 'shutting_down' : 'queued',
|
|
3476
|
-
action: pending.action || 'shutdown',
|
|
3477
|
-
restart: pending.action === 'restart',
|
|
3478
|
-
reason: pending.reason || null,
|
|
3479
|
-
requestedBy: pending.requestedBy || null,
|
|
3480
|
-
requestedAt: pending.requestedAt || null,
|
|
3481
|
-
};
|
|
3524
|
+
return restartPictureCollector({ action });
|
|
3482
3525
|
}
|
|
3526
|
+
const relayRestartQueue = createRelayRestartQueue({
|
|
3527
|
+
getCounts: () => queueCounts(),
|
|
3528
|
+
collectPicture: collectRestartPicture,
|
|
3529
|
+
startShutdown: ({ action, reason }) => {
|
|
3530
|
+
const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
|
|
3531
|
+
sessionWorkerWebSocketService.emitDraining(`api-${action}:${reason}`);
|
|
3532
|
+
void shutdownRuntime(`api-${action}:${reason}`, { exitCode });
|
|
3533
|
+
},
|
|
3534
|
+
getMaxWaitMs: () => (relayRestartMaxWaitOverrideMs !== null
|
|
3535
|
+
? relayRestartMaxWaitOverrideMs
|
|
3536
|
+
: getRelayRestartMaxWaitMinutes() * 60_000),
|
|
3537
|
+
isShuttingDown: () => runtimeShutdownStarted,
|
|
3538
|
+
onStateChange: (state) => {
|
|
3539
|
+
try { io.emit('relay_shutdown_state', state); } catch {}
|
|
3540
|
+
},
|
|
3541
|
+
onMaxWaitReached: (state) => {
|
|
3542
|
+
const sessions = Array.isArray(state.waitingFor) ? state.waitingFor.length : 0;
|
|
3543
|
+
const minutes = Math.max(1, Math.round(Number(state.maxWaitMs || 0) / 60_000));
|
|
3544
|
+
void pushDispatchService.notifyRelayRestart({
|
|
3545
|
+
body: `The queued relay ${state.action} has waited ${minutes} min for ${sessions} session${sessions === 1 ? '' : 's'}. Restart now, keep waiting or cancel it.`,
|
|
3546
|
+
});
|
|
3547
|
+
},
|
|
3548
|
+
logger: console,
|
|
3549
|
+
logPrefix: () => runtimeLogPrefix(),
|
|
3550
|
+
});
|
|
3483
3551
|
|
|
3484
|
-
// Every client shows a banner while a shutdown/restart is queued, so the
|
|
3485
|
-
// state goes out on each transition (request, cancel, drain) and on connect.
|
|
3486
3552
|
function relayShutdownStatePayload() {
|
|
3487
|
-
return
|
|
3488
|
-
}
|
|
3489
|
-
function emitRelayShutdownState() {
|
|
3490
|
-
try { io.emit('relay_shutdown_state', relayShutdownStatePayload()); } catch {}
|
|
3553
|
+
return relayRestartQueue.getState();
|
|
3491
3554
|
}
|
|
3492
3555
|
|
|
3493
3556
|
function cancelRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
3494
|
-
|
|
3495
|
-
return { cancelled: false, status: 'shutting_down', ...relayShutdownStatePayload() };
|
|
3496
|
-
}
|
|
3497
|
-
if (!pendingRelayShutdownRequest) {
|
|
3498
|
-
return { cancelled: false, ...relayShutdownStatePayload() };
|
|
3499
|
-
}
|
|
3500
|
-
const dropped = pendingRelayShutdownRequest;
|
|
3501
|
-
pendingRelayShutdownRequest = null;
|
|
3502
|
-
if (runtimeTimers.shutdownDrain) {
|
|
3503
|
-
try { clearInterval(runtimeTimers.shutdownDrain); } catch {}
|
|
3504
|
-
runtimeTimers.shutdownDrain = null;
|
|
3505
|
-
}
|
|
3506
|
-
console.log(
|
|
3507
|
-
`${runtimeLogPrefix()}Relay ${dropped.action || 'shutdown'} request cancelled by `
|
|
3508
|
-
+ `${String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api'}`,
|
|
3509
|
-
);
|
|
3510
|
-
emitRelayShutdownState();
|
|
3511
|
-
return { cancelled: true, dropped, ...relayShutdownStatePayload() };
|
|
3557
|
+
return relayRestartQueue.cancel({ requestedBy });
|
|
3512
3558
|
}
|
|
3513
3559
|
|
|
3514
|
-
function
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
const restartRequested = normalizeRestartRequestFlag(restart);
|
|
3518
|
-
const requestedAction = restartRequested ? 'restart' : 'shutdown';
|
|
3519
|
-
if (runtimeShutdownStarted) {
|
|
3520
|
-
return {
|
|
3521
|
-
accepted: false,
|
|
3522
|
-
status: 'shutting_down',
|
|
3523
|
-
action: requestedAction,
|
|
3524
|
-
restart: restartRequested,
|
|
3525
|
-
reason: safeReason,
|
|
3526
|
-
};
|
|
3527
|
-
}
|
|
3528
|
-
|
|
3529
|
-
// Shutdown is deferred until no turn is processing; it never interrupts an
|
|
3530
|
-
// active turn. From here on no pending row is handed out (the dequeue paths
|
|
3531
|
-
// read relayShutdownQueued): a turn started meanwhile would postpone the
|
|
3532
|
-
// restart again. Pending and parked rows wait in the database and run
|
|
3533
|
-
// after the restart.
|
|
3534
|
-
if (!pendingRelayShutdownRequest) {
|
|
3535
|
-
pendingRelayShutdownRequest = {
|
|
3536
|
-
action: requestedAction,
|
|
3537
|
-
reason: safeReason,
|
|
3538
|
-
requestedBy: safeRequestedBy,
|
|
3539
|
-
requestedAt: new Date().toISOString(),
|
|
3540
|
-
};
|
|
3541
|
-
} else if (restartRequested && pendingRelayShutdownRequest.action !== 'restart') {
|
|
3542
|
-
pendingRelayShutdownRequest = {
|
|
3543
|
-
...pendingRelayShutdownRequest,
|
|
3544
|
-
action: 'restart',
|
|
3545
|
-
reason: safeReason,
|
|
3546
|
-
requestedBy: safeRequestedBy,
|
|
3547
|
-
requestedAt: new Date().toISOString(),
|
|
3548
|
-
};
|
|
3549
|
-
}
|
|
3550
|
-
|
|
3551
|
-
const tryShutdownWhenIdle = () => {
|
|
3552
|
-
if (runtimeShutdownStarted) return;
|
|
3553
|
-
const counts = queueCounts();
|
|
3554
|
-
if (!queuedShutdownCanProceed(counts)) return;
|
|
3555
|
-
const requestInfo = pendingRelayShutdownRequest;
|
|
3556
|
-
pendingRelayShutdownRequest = null;
|
|
3557
|
-
if (runtimeTimers.shutdownDrain) {
|
|
3558
|
-
try { clearInterval(runtimeTimers.shutdownDrain); } catch {}
|
|
3559
|
-
runtimeTimers.shutdownDrain = null;
|
|
3560
|
-
}
|
|
3561
|
-
const action = requestInfo?.action === 'restart' ? 'restart' : 'shutdown';
|
|
3562
|
-
const exitCode = action === 'restart' ? RELAY_RESTART_EXIT_CODE : 0;
|
|
3563
|
-
console.log(
|
|
3564
|
-
`${runtimeLogPrefix()}Relay ${action} request accepted by ${requestInfo?.requestedBy || 'unknown'}`
|
|
3565
|
-
+ `; no turn is processing (${counts.pendingCount} pending, ${counts.parkedCount} parked wait), exiting now (reason=${requestInfo?.reason || 'manual-request'})`,
|
|
3566
|
-
);
|
|
3567
|
-
sessionWorkerWebSocketService.emitDraining(`api-${action}:${requestInfo?.reason || 'manual-request'}`);
|
|
3568
|
-
void shutdownRuntime(`api-${action}:${requestInfo?.reason || 'manual-request'}`, { exitCode });
|
|
3569
|
-
emitRelayShutdownState();
|
|
3570
|
-
};
|
|
3560
|
+
function keepWaitingForRelayShutdown({ requestedBy = 'localhost-api' } = {}) {
|
|
3561
|
+
return relayRestartQueue.keepWaiting({ requestedBy });
|
|
3562
|
+
}
|
|
3571
3563
|
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
}
|
|
3575
|
-
tryShutdownWhenIdle();
|
|
3576
|
-
if (pendingRelayShutdownRequest) emitRelayShutdownState();
|
|
3577
|
-
const counts = queueCounts();
|
|
3578
|
-
return {
|
|
3579
|
-
accepted: true,
|
|
3580
|
-
status: 'queued',
|
|
3581
|
-
action: pendingRelayShutdownRequest?.action || requestedAction,
|
|
3582
|
-
restart: (pendingRelayShutdownRequest?.action || requestedAction) === 'restart',
|
|
3583
|
-
requestedAt: pendingRelayShutdownRequest?.requestedAt || new Date().toISOString(),
|
|
3584
|
-
reason: pendingRelayShutdownRequest?.reason || safeReason,
|
|
3585
|
-
requestedBy: pendingRelayShutdownRequest?.requestedBy || safeRequestedBy,
|
|
3586
|
-
queue: counts,
|
|
3587
|
-
};
|
|
3564
|
+
function requestRelayShutdown(options = {}) {
|
|
3565
|
+
return relayRestartQueue.request(options);
|
|
3588
3566
|
}
|
|
3589
3567
|
|
|
3590
3568
|
function toNullableInt(value) {
|
|
@@ -5248,10 +5226,14 @@ function previewCardsForResponse(responseMessageId) {
|
|
|
5248
5226
|
.slice(0, 5);
|
|
5249
5227
|
}
|
|
5250
5228
|
|
|
5251
|
-
// Live background-task sets, as the session workers publish them.
|
|
5252
|
-
// only: after a runtime start the heartbeat's digest brings each
|
|
5253
|
-
// (
|
|
5254
|
-
|
|
5229
|
+
// Live background-task sets, as the session workers publish them. The sets
|
|
5230
|
+
// are memory only: after a runtime start the heartbeat's digest brings each
|
|
5231
|
+
// set back. The console rows (the output file of a running shell, the ended
|
|
5232
|
+
// rows of the last hour) are stored (migration 0018) and restored here; a
|
|
5233
|
+
// running one is held until its worker reports (services/background-task-store.mjs).
|
|
5234
|
+
const backgroundTaskStore = createBackgroundTaskStore({
|
|
5235
|
+
persistence: createSqliteBackgroundTaskPersistence(db, { logger: console }),
|
|
5236
|
+
});
|
|
5255
5237
|
// The output a worker posts for a task with no output file (the console's
|
|
5256
5238
|
// fallback); memory only, an hour.
|
|
5257
5239
|
const taskOutputSnapshots = createTaskOutputSnapshots();
|
|
@@ -5366,6 +5348,29 @@ const usageLimitPauseService = createUsageLimitPauseService({
|
|
|
5366
5348
|
logger: console,
|
|
5367
5349
|
});
|
|
5368
5350
|
|
|
5351
|
+
// Turns a restart cut off for good (their worker went with the relay, and a
|
|
5352
|
+
// second delivery would run the prompt twice): with the setting on, their
|
|
5353
|
+
// session gets one note to carry on. The rows processing right now are the
|
|
5354
|
+
// ones the runtime before left; the recovery paths below report what became
|
|
5355
|
+
// of each (services/restart-resume-service.mjs).
|
|
5356
|
+
const restartResumeService = createRestartResumeService({
|
|
5357
|
+
db,
|
|
5358
|
+
stmts,
|
|
5359
|
+
isEnabled: getRestartResumeEnabled,
|
|
5360
|
+
emit: (event, payload) => io.emit(event, payload),
|
|
5361
|
+
// The note is an ordinary pending message: the primer starts a stopped
|
|
5362
|
+
// worker, the socket service hands it to a running one.
|
|
5363
|
+
wake: () => {
|
|
5364
|
+
void primePendingSessionWorkers('restart-resume');
|
|
5365
|
+
sessionWorkerWebSocketService?.emitQueueChanged?.('restart-resume');
|
|
5366
|
+
},
|
|
5367
|
+
logger: console,
|
|
5368
|
+
});
|
|
5369
|
+
restartResumeService.captureBoot();
|
|
5370
|
+
function noteRestartTurnOutcome(row, outcome) {
|
|
5371
|
+
try { restartResumeService.noteOutcome(row, outcome); } catch {}
|
|
5372
|
+
}
|
|
5373
|
+
|
|
5369
5374
|
// ─── Relay events ─────────────────────────────────────────────────────────────
|
|
5370
5375
|
// What each session is doing (state, tasks, quiet time) and the subscriptions
|
|
5371
5376
|
// through which a watching session is woken with the events of the sessions
|
|
@@ -5539,6 +5544,7 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
|
|
|
5539
5544
|
for (const row of staleContinuations) {
|
|
5540
5545
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
5541
5546
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
5547
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
5542
5548
|
// A teardown is a turn ending with no reply; it must be visible in the
|
|
5543
5549
|
// console log (the only trace of a silent one was `ignored=already_failed`
|
|
5544
5550
|
// hours later, when the worker's reply arrived for the dead row).
|
|
@@ -5547,13 +5553,19 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
|
|
|
5547
5553
|
console.warn(`${runtimeLogPrefix()}STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
|
|
5548
5554
|
}
|
|
5549
5555
|
const listedRows = stmts.listRecoverableProcessing.all(params);
|
|
5550
|
-
for (const row of listedRows.filter((entry) => entry?.consumed_at))
|
|
5556
|
+
for (const row of listedRows.filter((entry) => entry?.consumed_at)) {
|
|
5557
|
+
failConsumedRowTerminally(row, 'stale-recovery');
|
|
5558
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
5559
|
+
}
|
|
5551
5560
|
const allRows = listedRows.filter((entry) => !entry?.consumed_at);
|
|
5552
5561
|
if (!allRows.length) return [];
|
|
5553
5562
|
// Enforce the retry budget before requeueing: failing these rows first
|
|
5554
5563
|
// flips them out of 'processing' so the recovery UPDATE below skips them.
|
|
5555
5564
|
const overBudget = allRows.filter((row) => Number(row?.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES);
|
|
5556
|
-
for (const row of overBudget)
|
|
5565
|
+
for (const row of overBudget) {
|
|
5566
|
+
failRecoveredRowTerminally(row, 'stale-recovery');
|
|
5567
|
+
noteRestartTurnOutcome(row, 'failed');
|
|
5568
|
+
}
|
|
5557
5569
|
const rows = allRows.filter((row) => Number(row?.retry_count || 0) + 1 < MAX_REQUEUE_RETRIES);
|
|
5558
5570
|
if (!rows.length) return [];
|
|
5559
5571
|
|
|
@@ -5578,6 +5590,7 @@ function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso
|
|
|
5578
5590
|
|
|
5579
5591
|
for (const row of rows) {
|
|
5580
5592
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
5593
|
+
noteRestartTurnOutcome(row, 'requeued');
|
|
5581
5594
|
}
|
|
5582
5595
|
io.emit('queue_updated', { recovered: rows.length });
|
|
5583
5596
|
return rows;
|
|
@@ -6442,14 +6455,17 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
|
6442
6455
|
// No user prompt to replay: a dead worker's continuation is torn down.
|
|
6443
6456
|
stmts.dropStaleContinuation?.run?.(row.id);
|
|
6444
6457
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
6458
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
6445
6459
|
continue;
|
|
6446
6460
|
}
|
|
6447
6461
|
if (row.consumed_at) {
|
|
6448
6462
|
failConsumedRowTerminally(row, `worker-died-${reason}`);
|
|
6463
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
6449
6464
|
continue;
|
|
6450
6465
|
}
|
|
6451
6466
|
if (Number(row.retry_count || 0) + 1 >= MAX_REQUEUE_RETRIES) {
|
|
6452
6467
|
failRecoveredRowTerminally(row, `worker-died-${reason}`);
|
|
6468
|
+
noteRestartTurnOutcome(row, 'failed');
|
|
6453
6469
|
continue;
|
|
6454
6470
|
}
|
|
6455
6471
|
// The dead worker's subagent runs can never be terminated by the replay
|
|
@@ -6457,6 +6473,7 @@ function recoverDeadWorkerProcessingRows(sessionId, reason) {
|
|
|
6457
6473
|
closeStrandedSubagentRuns(stmts.listRunningSubagentRunsByQueueMessage?.all?.(row.id) || [], row.id);
|
|
6458
6474
|
stmts.recoverProcessingRowKeepOwner?.run?.(requeueAt, row.id);
|
|
6459
6475
|
io.emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
6476
|
+
noteRestartTurnOutcome(row, 'requeued');
|
|
6460
6477
|
recovered += 1;
|
|
6461
6478
|
}
|
|
6462
6479
|
if (recovered > 0) {
|
|
@@ -6569,7 +6586,7 @@ function sessionHasBackgroundWork(sid) {
|
|
|
6569
6586
|
const detachedWorkerWatch = createDetachedWorkerWatch({
|
|
6570
6587
|
isEnabled: () => featureFlags?.SESSION_WORKER_ROUTING_ENABLED === true
|
|
6571
6588
|
&& !runtimeShutdownStarted
|
|
6572
|
-
&& !
|
|
6589
|
+
&& !relayRestartQueue.isQueued(),
|
|
6573
6590
|
listWaitingSessionIds: () => (stmts.listPendingWorkerOwnerSessionIds?.all?.(25) || [])
|
|
6574
6591
|
.map((row) => row?.sdk_session_id),
|
|
6575
6592
|
hasWorkerSocket: (sid) => sessionWorkerWebSocketService.hasWorkerSocket(sid) === true,
|
|
@@ -6766,10 +6783,10 @@ const runtimeState = {
|
|
|
6766
6783
|
get relayPaused() { return relayPaused; },
|
|
6767
6784
|
set relayPaused(value) { relayPaused = value; },
|
|
6768
6785
|
get ttyConsoleActive() { return Boolean(ttyConsoleRuntime?.tty); },
|
|
6769
|
-
get relayShutdown() { return
|
|
6786
|
+
get relayShutdown() { return relayRestartQueue.getState(); },
|
|
6770
6787
|
// A shutdown or restart waits for the processing turns: no pending row
|
|
6771
|
-
// goes out meanwhile (
|
|
6772
|
-
get relayShutdownQueued() { return
|
|
6788
|
+
// goes out meanwhile (services/relay-restart-queue.mjs).
|
|
6789
|
+
get relayShutdownQueued() { return relayRestartQueue.isQueued(); },
|
|
6773
6790
|
get hostSuspend() { return hostSuspendService.getState(); },
|
|
6774
6791
|
get usageLimit() {
|
|
6775
6792
|
return { account: usageLimitPauseService.getAccountState(), pauses: usageLimitPauseService.listPauses() };
|
|
@@ -7017,6 +7034,9 @@ const sharedRouteDeps = {
|
|
|
7017
7034
|
pokeTaskHealth: () => taskHealthPoller.poke(),
|
|
7018
7035
|
getTaskStallMinutes,
|
|
7019
7036
|
setTaskStallMinutes,
|
|
7037
|
+
getRestartResumeEnabled,
|
|
7038
|
+
setRestartResumeEnabled,
|
|
7039
|
+
noteRestartTurnOutcome,
|
|
7020
7040
|
// A closure, not the registry itself: this deps object is built before the
|
|
7021
7041
|
// preview lane is constructed further down the file.
|
|
7022
7042
|
listConversationPreviews: (conversationId) => previewRegistry.listForConversation(conversationId),
|
|
@@ -7146,6 +7166,16 @@ registerRelayEventRoutes(app, {
|
|
|
7146
7166
|
relayEventService,
|
|
7147
7167
|
remoteRelayInbound,
|
|
7148
7168
|
});
|
|
7169
|
+
registerRelayRestartRoutes(app, {
|
|
7170
|
+
auth,
|
|
7171
|
+
// Read only: a GET neither fires the restart nor asks the question.
|
|
7172
|
+
getState: () => relayRestartQueue.getState(),
|
|
7173
|
+
collectRestartPicture,
|
|
7174
|
+
keepWaiting: keepWaitingForRelayShutdown,
|
|
7175
|
+
getMaxWaitMinutes: getRelayRestartMaxWaitMinutes,
|
|
7176
|
+
setMaxWaitMinutes: setRelayRestartMaxWaitMinutes,
|
|
7177
|
+
maxWaitOverrideMs: relayRestartMaxWaitOverrideMs,
|
|
7178
|
+
});
|
|
7149
7179
|
registerAboutRoutes(app, {
|
|
7150
7180
|
auth,
|
|
7151
7181
|
aboutService: createAboutService({
|
|
@@ -7236,6 +7266,8 @@ function recoverStaleMessages() {
|
|
|
7236
7266
|
const ceilingBeforeIso = ceilingMs > 0 ? new Date(Date.now() - ceilingMs).toISOString() : null;
|
|
7237
7267
|
const requeueAt = addMsIso(cliOnline ? 30_000 : 2_000);
|
|
7238
7268
|
const recoveredRows = recoverProcessingOlderThan(cutoff, requeueAt, { ceilingBeforeIso });
|
|
7269
|
+
// Turns of the start that ended on their own decide their session's note.
|
|
7270
|
+
try { restartResumeService.tick(); } catch {}
|
|
7239
7271
|
if (recoveredRows.length > 0) {
|
|
7240
7272
|
const ceilingNote = ceilingBeforeIso ? `, ceiling ${Math.round(ceilingMs / 60_000)}min` : '';
|
|
7241
7273
|
console.log(
|
|
@@ -7561,6 +7593,27 @@ const taskOutputRooms = createTaskOutputRooms({
|
|
|
7561
7593
|
// the live tasks with an output file.
|
|
7562
7594
|
const taskHealthPoller = createTaskHealthPoller({ store: backgroundTaskStore, io });
|
|
7563
7595
|
taskHealthPoller.start();
|
|
7596
|
+
// Console tasks held since the restart whose worker has not reported within
|
|
7597
|
+
// twice the boot window (at least 15 s: a test relay has none): that worker
|
|
7598
|
+
// did not survive the restart. They are marked ended (their output stays
|
|
7599
|
+
// readable for the hour), never running.
|
|
7600
|
+
runtimeTimers.restoredTaskSettle = setTimeout(() => {
|
|
7601
|
+
runtimeTimers.restoredTaskSettle = null;
|
|
7602
|
+
const keys = backgroundTaskStore.endUnconfirmed();
|
|
7603
|
+
if (!keys.length) return;
|
|
7604
|
+
console.log(`${runtimeLogPrefix()}BG-TASKS ${keys.length} session(s) did not report after the restart; their console tasks are marked ended`);
|
|
7605
|
+
for (const key of keys) {
|
|
7606
|
+
try {
|
|
7607
|
+
io.emit('background_tasks', {
|
|
7608
|
+
conversationId: key,
|
|
7609
|
+
tasks: backgroundTaskStore.withHealth(backgroundTaskStore.get(key), key),
|
|
7610
|
+
endedTasks: backgroundTaskStore.ended(key),
|
|
7611
|
+
});
|
|
7612
|
+
} catch {}
|
|
7613
|
+
}
|
|
7614
|
+
taskHealthPoller.poke();
|
|
7615
|
+
}, Math.max(15_000, 2 * resolveBootReportingWindowMs(process.env)));
|
|
7616
|
+
runtimeTimers.restoredTaskSettle?.unref?.();
|
|
7564
7617
|
// The handshake presents the token as `auth.token` or the cookie; a query
|
|
7565
7618
|
// `token` is not read (see services/relay-auth.mjs).
|
|
7566
7619
|
io.use(relayAuth.socketMiddleware);
|
|
@@ -7729,6 +7782,7 @@ function clearRuntimeTimers() {
|
|
|
7729
7782
|
for (const timer of Object.values(runtimeTimers)) {
|
|
7730
7783
|
if (!timer) continue;
|
|
7731
7784
|
try { clearInterval(timer); } catch {}
|
|
7785
|
+
try { clearTimeout(timer); } catch {}
|
|
7732
7786
|
}
|
|
7733
7787
|
subagentRecordRetention?.stop();
|
|
7734
7788
|
try { taskHealthPoller.stop(); } catch {}
|
|
@@ -7739,7 +7793,8 @@ function clearRuntimeTimers() {
|
|
|
7739
7793
|
runtimeTimers.questionExpiry = null;
|
|
7740
7794
|
runtimeTimers.pendingWorkerPrime = null;
|
|
7741
7795
|
runtimeTimers.sharedViewerPrune = null;
|
|
7742
|
-
runtimeTimers.
|
|
7796
|
+
runtimeTimers.restoredTaskSettle = null;
|
|
7797
|
+
try { relayRestartQueue.dispose(); } catch {}
|
|
7743
7798
|
}
|
|
7744
7799
|
|
|
7745
7800
|
let runtimeShutdownPromise = null;
|
|
@@ -68,32 +68,33 @@ const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
|
|
|
68
68
|
* The commit a git checkout has checked out, read from its .git files rather
|
|
69
69
|
* than from a `git` process: HEAD, then the loose ref, then packed-refs. A
|
|
70
70
|
* linked worktree's .git is a file naming its git dir, whose refs live in the
|
|
71
|
-
* common dir. Anything unexpected reads as null.
|
|
71
|
+
* common dir. Anything unexpected reads as null. `pathImpl` is the host's path
|
|
72
|
+
* module; tests pass path.posix and path.win32.
|
|
72
73
|
*/
|
|
73
|
-
export function readGitCommit(root, { fsImpl = fs } = {}) {
|
|
74
|
+
export function readGitCommit(root, { fsImpl = fs, pathImpl = path } = {}) {
|
|
74
75
|
if (!root) return null;
|
|
75
|
-
const dotGit =
|
|
76
|
+
const dotGit = pathImpl.join(root, '.git');
|
|
76
77
|
let gitDir = dotGit;
|
|
77
78
|
const pointer = readFileText(fsImpl, dotGit);
|
|
78
79
|
if (pointer !== null) {
|
|
79
80
|
const match = pointer.match(/^gitdir:\s*(.+)$/m);
|
|
80
81
|
if (!match) return null;
|
|
81
|
-
gitDir =
|
|
82
|
+
gitDir = pathImpl.resolve(root, match[1].trim());
|
|
82
83
|
}
|
|
83
|
-
const head = readFileText(fsImpl,
|
|
84
|
+
const head = readFileText(fsImpl, pathImpl.join(gitDir, 'HEAD'));
|
|
84
85
|
if (head === null) return null;
|
|
85
86
|
const headText = head.trim();
|
|
86
87
|
if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
|
|
87
88
|
const refMatch = headText.match(/^ref:\s*(\S+)$/);
|
|
88
89
|
if (!refMatch) return null;
|
|
89
90
|
const ref = refMatch[1];
|
|
90
|
-
const commonDirText = readFileText(fsImpl,
|
|
91
|
-
const commonDir = commonDirText ?
|
|
91
|
+
const commonDirText = readFileText(fsImpl, pathImpl.join(gitDir, 'commondir'));
|
|
92
|
+
const commonDir = commonDirText ? pathImpl.resolve(gitDir, commonDirText.trim()) : gitDir;
|
|
92
93
|
for (const dir of new Set([gitDir, commonDir])) {
|
|
93
|
-
const loose = readFileText(fsImpl,
|
|
94
|
+
const loose = readFileText(fsImpl, pathImpl.join(dir, ...ref.split('/')));
|
|
94
95
|
if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
|
|
95
96
|
}
|
|
96
|
-
const packed = readFileText(fsImpl,
|
|
97
|
+
const packed = readFileText(fsImpl, pathImpl.join(commonDir, 'packed-refs')) || '';
|
|
97
98
|
for (const line of packed.split(/\r?\n/)) {
|
|
98
99
|
const [hash, name] = line.trim().split(/\s+/);
|
|
99
100
|
if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
|
|
@@ -102,11 +103,11 @@ export function readGitCommit(root, { fsImpl = fs } = {}) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
|
|
105
|
-
export function resolveBuildCommit(packageRoot, { fsImpl = fs } = {}) {
|
|
106
|
-
const fromGit = readGitCommit(packageRoot, { fsImpl });
|
|
106
|
+
export function resolveBuildCommit(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
107
|
+
const fromGit = readGitCommit(packageRoot, { fsImpl, pathImpl });
|
|
107
108
|
if (fromGit) return fromGit;
|
|
108
109
|
try {
|
|
109
|
-
const stamp = String(JSON.parse(readFileText(fsImpl,
|
|
110
|
+
const stamp = String(JSON.parse(readFileText(fsImpl, pathImpl.join(packageRoot, 'package.json')) || '{}')?.gitHead || '').trim();
|
|
110
111
|
return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
|
|
111
112
|
} catch {
|
|
112
113
|
return null;
|