@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/README.md +186 -11
  5. package/server/claude-worker/claude-session-process.mjs +53 -2
  6. package/server/claude-worker/task-output-discovery.mjs +28 -2
  7. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
  9. package/server/db-schema.mjs +10 -0
  10. package/server/grok-worker/acp-host-services.mjs +6 -3
  11. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  12. package/server/migrations/0018-background-task-rows.mjs +101 -0
  13. package/server/public/app/action-confirmations.js +12 -15
  14. package/server/public/app/api-client.js +57 -3
  15. package/server/public/app/background-tasks-view.mjs +56 -7
  16. package/server/public/app/bootstrap.js +7 -0
  17. package/server/public/app/conversation-preferences.mjs +10 -4
  18. package/server/public/app/conversation-view.js +33 -9
  19. package/server/public/app/host-suspend-ui.js +45 -44
  20. package/server/public/app/journal-view.js +9 -3
  21. package/server/public/app/push-settings.js +1 -0
  22. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  23. package/server/public/app/relay-restart-note-view.mjs +36 -0
  24. package/server/public/app/relay-restart-ui.js +568 -0
  25. package/server/public/app/settings-modal.js +52 -0
  26. package/server/public/app/socket-handlers.js +1 -1
  27. package/server/public/app/task-console-render.mjs +171 -7
  28. package/server/public/app/task-console.mjs +111 -11
  29. package/server/public/app/task-output-text.mjs +15 -6
  30. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  31. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  32. package/server/public/index.html +67 -0
  33. package/server/relay-tools.md +5 -0
  34. package/server/repositories/message-repository.mjs +3 -0
  35. package/server/repositories/remote-relay-repository.mjs +4 -2
  36. package/server/routes/messages-routes.mjs +155 -27
  37. package/server/routes/relay-board-routes.mjs +19 -2
  38. package/server/routes/relay-restart-routes.mjs +73 -0
  39. package/server/routes/sessions-routes.mjs +75 -3
  40. package/server/server-runtime.mjs +192 -137
  41. package/server/services/about-service.mjs +13 -12
  42. package/server/services/background-task-persistence.mjs +133 -0
  43. package/server/services/background-task-store.mjs +240 -13
  44. package/server/services/conversation-model-fallback.mjs +57 -0
  45. package/server/services/host-suspend-command.mjs +2 -1
  46. package/server/services/preview-proxy-server.mjs +14 -1
  47. package/server/services/process-list-service.mjs +2 -1
  48. package/server/services/provider-model-selection.mjs +195 -0
  49. package/server/services/push-dispatch-service.mjs +9 -2
  50. package/server/services/relay-event-service.mjs +27 -9
  51. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  52. package/server/services/relay-restart-picture.mjs +209 -0
  53. package/server/services/relay-restart-queue.mjs +380 -0
  54. package/server/services/relay-shutdown-gate.mjs +4 -1
  55. package/server/services/remote-relay-dispatcher.mjs +187 -19
  56. package/server/services/restart-resume-service.mjs +303 -0
  57. package/server/services/session-worker-process-service.mjs +6 -4
  58. package/server/services/task-output-service.mjs +217 -18
  59. package/shared/relay-events.mjs +55 -2
  60. package/shared/remote-relay-contract.mjs +15 -8
  61. package/shared/task-output-text.mjs +196 -83
  62. 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, queuedShutdownCanProceed, relayShutdownGateReply } from './services/relay-shutdown-gate.mjs';
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
- shutdownDrain: null,
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
- let pendingRelayShutdownRequest = null;
3455
- function normalizeRestartRequestFlag(value) {
3456
- if (value === true || value === 1) return true;
3457
- if (value === false || value === 0 || value == null) return false;
3458
- const normalized = String(value).trim().toLowerCase();
3459
- return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
3460
- }
3461
-
3462
- function getRelayShutdownState() {
3463
- const pending = pendingRelayShutdownRequest;
3464
- if (!pending) {
3465
- return {
3466
- status: runtimeShutdownStarted ? 'shutting_down' : 'idle',
3467
- action: runtimeShutdownStarted ? 'shutdown' : null,
3468
- restart: false,
3469
- reason: null,
3470
- requestedBy: null,
3471
- requestedAt: null,
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 { ...getRelayShutdownState(), queue: queueCounts() };
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
- if (runtimeShutdownStarted) {
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 requestRelayShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false } = {}) {
3515
- const safeReason = String(reason || 'manual-request').trim().slice(0, 140) || 'manual-request';
3516
- const safeRequestedBy = String(requestedBy || 'localhost-api').trim().slice(0, 80) || 'localhost-api';
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
- if (!runtimeTimers.shutdownDrain) {
3573
- runtimeTimers.shutdownDrain = setInterval(tryShutdownWhenIdle, 1000);
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. Memory
5252
- // only: after a runtime start the heartbeat's digest brings each set back
5253
- // (services/background-task-store.mjs).
5254
- const backgroundTaskStore = createBackgroundTaskStore();
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)) failConsumedRowTerminally(row, 'stale-recovery');
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) failRecoveredRowTerminally(row, 'stale-recovery');
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
- && !pendingRelayShutdownRequest,
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 getRelayShutdownState(); },
6786
+ get relayShutdown() { return relayRestartQueue.getState(); },
6770
6787
  // A shutdown or restart waits for the processing turns: no pending row
6771
- // goes out meanwhile (see requestRelayShutdown).
6772
- get relayShutdownQueued() { return !!pendingRelayShutdownRequest && !runtimeShutdownStarted; },
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.shutdownDrain = null;
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 = path.join(root, '.git');
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 = path.resolve(root, match[1].trim());
82
+ gitDir = pathImpl.resolve(root, match[1].trim());
82
83
  }
83
- const head = readFileText(fsImpl, path.join(gitDir, 'HEAD'));
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, path.join(gitDir, 'commondir'));
91
- const commonDir = commonDirText ? path.resolve(gitDir, commonDirText.trim()) : gitDir;
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, path.join(dir, ...ref.split('/')));
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, path.join(commonDir, 'packed-refs')) || '';
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, path.join(packageRoot, 'package.json')) || '{}')?.gitHead || '').trim();
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;