@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
@@ -0,0 +1,380 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * A queued relay restart (or shutdown), as every device sees it: what it
5
+ * waits for, how long it has waited, and the question it asks once it has
6
+ * waited too long.
7
+ *
8
+ * The request waits until no turn is processing (relay-shutdown-gate.mjs);
9
+ * while it waits, no pending row is handed to a worker. A turn that runs for
10
+ * hours would hold every session's new messages for hours, so the wait has a
11
+ * maximum (Settings › General). Past it the relay does not force anything:
12
+ * the request stays queued and turns into a question — restart now, keep
13
+ * waiting another period, or cancel — that the banner shows and a push
14
+ * notification announces once. Only the user, or an API caller that says
15
+ * `immediate`, restarts with turns running.
16
+ *
17
+ * Memory only, like the request itself. The service is pure orchestration:
18
+ * the queue counts, the picture of what runs, the shutdown itself and the
19
+ * timers are injected, so the state machine is unit-testable with fake
20
+ * clocks.
21
+ */
22
+
23
+ import { queuedShutdownCanProceed } from './relay-shutdown-gate.mjs';
24
+
25
+ export const RELAY_RESTART_MAX_WAIT_SETTING_KEY = 'relay_restart_max_wait_minutes';
26
+ // 0 is "no limit": the queued restart waits, and holds new turns, until the
27
+ // running turns end or someone decides.
28
+ export const RELAY_RESTART_MAX_WAIT_CHOICES = Object.freeze([5, 10, 15, 30, 60, 120, 240, 0]);
29
+ export const DEFAULT_RELAY_RESTART_MAX_WAIT_MINUTES = 30;
30
+ const DEFAULT_POLL_MS = 1000;
31
+
32
+ function asMaxWaitChoice(value) {
33
+ if (value === null || value === undefined) return null;
34
+ if (typeof value === 'string' && value.trim() === '') return null;
35
+ const numeric = Number(value);
36
+ if (!Number.isInteger(numeric)) return null;
37
+ return RELAY_RESTART_MAX_WAIT_CHOICES.includes(numeric) ? numeric : null;
38
+ }
39
+
40
+ /** An unset or unreadable setting means the default, not "no limit". */
41
+ export function readRelayRestartMaxWaitSetting(storedValue) {
42
+ const minutes = asMaxWaitChoice(storedValue);
43
+ return minutes === null ? DEFAULT_RELAY_RESTART_MAX_WAIT_MINUTES : minutes;
44
+ }
45
+
46
+ export function parseRelayRestartMaxWaitUpdate(value) {
47
+ const minutes = asMaxWaitChoice(value);
48
+ if (minutes === null) {
49
+ return { ok: false, error: `maxWaitMinutes must be one of ${RELAY_RESTART_MAX_WAIT_CHOICES.join(', ')} (0 = no limit)` };
50
+ }
51
+ return { ok: true, minutes };
52
+ }
53
+
54
+ /**
55
+ * A test relay's override of the setting, in milliseconds
56
+ * (OAR_RELAY_RESTART_MAX_WAIT_MS): null when unset or unreadable. Lets an
57
+ * end-to-end run reach the question in seconds.
58
+ */
59
+ export function resolveRelayRestartMaxWaitOverrideMs(env = process.env) {
60
+ const text = String(env?.OAR_RELAY_RESTART_MAX_WAIT_MS ?? '').trim();
61
+ if (!text) return null;
62
+ const numeric = Number(text);
63
+ if (!Number.isFinite(numeric) || numeric < 0) return null;
64
+ return Math.min(Math.round(numeric), 24 * 60 * 60 * 1000);
65
+ }
66
+
67
+ function clampText(value, max, fallback) {
68
+ const text = String(value ?? '').trim().slice(0, max);
69
+ return text || fallback;
70
+ }
71
+
72
+ export function normalizeRestartRequestFlag(value) {
73
+ if (value === true || value === 1) return true;
74
+ if (value === false || value === 0 || value == null) return false;
75
+ const normalized = String(value).trim().toLowerCase();
76
+ return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
77
+ }
78
+
79
+ function iso(ms) {
80
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
81
+ }
82
+
83
+ const EMPTY_PICTURE = Object.freeze({
84
+ waitingFor: [],
85
+ held: { count: 0, conversations: [] },
86
+ backgroundTasks: [],
87
+ survival: null,
88
+ });
89
+
90
+ export function createRelayRestartQueue({
91
+ // () => { pendingCount, processingCount, parkedCount }
92
+ getCounts,
93
+ // ({ action }) => { waitingFor, held, backgroundTasks, survival }; see
94
+ // relay-restart-picture.mjs. Optional: without it the state names nothing.
95
+ collectPicture = null,
96
+ // ({ action, reason, requestedBy, immediate, counts }) => void: exits the
97
+ // runtime. Called at most once per request.
98
+ startShutdown,
99
+ // () => the maximum wait in ms, 0 for no limit.
100
+ getMaxWaitMs = () => DEFAULT_RELAY_RESTART_MAX_WAIT_MINUTES * 60_000,
101
+ isShuttingDown = () => false,
102
+ onStateChange = null,
103
+ // (state) => void, once per waiting period that ran out.
104
+ onMaxWaitReached = null,
105
+ logger = console,
106
+ logPrefix = () => '',
107
+ now = () => Date.now(),
108
+ setIntervalImpl = setInterval,
109
+ clearIntervalImpl = clearInterval,
110
+ pollMs = DEFAULT_POLL_MS,
111
+ } = {}) {
112
+ if (typeof getCounts !== 'function') throw new Error('getCounts is required');
113
+ if (typeof startShutdown !== 'function') throw new Error('startShutdown is required');
114
+
115
+ let request = null;
116
+ let pollTimer = null;
117
+ let picture = EMPTY_PICTURE;
118
+ let lastEmittedKey = '';
119
+
120
+ function log(line) {
121
+ try { logger.log?.(`${logPrefix()}${line}`); } catch {}
122
+ }
123
+
124
+ function maxWaitMs() {
125
+ let value = 0;
126
+ try { value = Number(getMaxWaitMs()); } catch {}
127
+ return Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
128
+ }
129
+
130
+ function counts() {
131
+ try { return getCounts() || {}; } catch { return {}; }
132
+ }
133
+
134
+ function readPicture(action) {
135
+ if (typeof collectPicture !== 'function') return EMPTY_PICTURE;
136
+ try {
137
+ const next = collectPicture({ action }) || {};
138
+ return {
139
+ waitingFor: Array.isArray(next.waitingFor) ? next.waitingFor : [],
140
+ held: next.held && typeof next.held === 'object' ? next.held : { count: 0, conversations: [] },
141
+ backgroundTasks: Array.isArray(next.backgroundTasks) ? next.backgroundTasks : [],
142
+ survival: next.survival || null,
143
+ };
144
+ } catch (error) {
145
+ log(`Relay restart: could not read what runs (${error?.message || error})`);
146
+ return EMPTY_PICTURE;
147
+ }
148
+ }
149
+
150
+ function deadlineAt() {
151
+ if (!request) return null;
152
+ const limit = maxWaitMs();
153
+ return limit > 0 ? request.periodStartedAt + limit : null;
154
+ }
155
+
156
+ function publicState() {
157
+ const shuttingDown = isShuttingDown() === true;
158
+ const queue = counts();
159
+ if (!request) {
160
+ return {
161
+ status: shuttingDown ? 'shutting_down' : 'idle',
162
+ action: shuttingDown ? 'shutdown' : null,
163
+ restart: false,
164
+ reason: null,
165
+ requestedBy: null,
166
+ requestedAt: null,
167
+ queue,
168
+ maxWaitMs: maxWaitMs(),
169
+ };
170
+ }
171
+ const deadline = deadlineAt();
172
+ return {
173
+ status: shuttingDown ? 'shutting_down' : 'queued',
174
+ action: request.action,
175
+ restart: request.action === 'restart',
176
+ reason: request.reason,
177
+ requestedBy: request.requestedBy,
178
+ requestedAt: iso(request.requestedAt),
179
+ queue,
180
+ // While queued, no pending row goes to a worker (relay-shutdown-gate.mjs).
181
+ holdsNewTurns: true,
182
+ maxWaitMs: maxWaitMs(),
183
+ waitingSince: iso(request.periodStartedAt),
184
+ deadlineAt: iso(deadline),
185
+ // Set once the maximum wait ran out: the relay asks, it does not act.
186
+ question: request.askedAt ? { askedAt: iso(request.askedAt) } : null,
187
+ keptWaiting: request.keptWaiting,
188
+ waitingFor: picture.waitingFor,
189
+ held: picture.held,
190
+ backgroundTasks: picture.backgroundTasks,
191
+ survival: picture.survival,
192
+ };
193
+ }
194
+
195
+ function emit(force = false) {
196
+ const state = publicState();
197
+ // The waited time ticks on the client; only real changes go out.
198
+ const key = JSON.stringify([
199
+ state.status, state.action, state.requestedAt, state.waitingSince, state.deadlineAt, state.question,
200
+ // Not the raw queue counts: each new held message would rebuild every
201
+ // banner; `held` carries the count.
202
+ state.waitingFor, state.held, state.backgroundTasks, state.survival,
203
+ ]);
204
+ if (!force && key === lastEmittedKey) return;
205
+ lastEmittedKey = key;
206
+ try { onStateChange?.(state); } catch (error) {
207
+ log(`Relay restart: state listener failed (${error?.message || error})`);
208
+ }
209
+ }
210
+
211
+ function stopPolling() {
212
+ if (!pollTimer) return;
213
+ try { clearIntervalImpl(pollTimer); } catch {}
214
+ pollTimer = null;
215
+ }
216
+
217
+ function fire({ immediate = false, by = null } = {}) {
218
+ const info = request;
219
+ const queue = counts();
220
+ request = null;
221
+ picture = EMPTY_PICTURE;
222
+ stopPolling();
223
+ const action = info?.action === 'restart' ? 'restart' : 'shutdown';
224
+ const running = Number(queue.processingCount || 0);
225
+ if (immediate && running > 0) {
226
+ log(`Relay ${action} now, requested by ${by || info?.requestedBy || 'unknown'}: not waiting for ${running} running turn${running === 1 ? '' : 's'} (reason=${info?.reason || 'manual-request'})`);
227
+ } else {
228
+ log(
229
+ `Relay ${action} request accepted by ${info?.requestedBy || 'unknown'}`
230
+ + `; no turn is processing (${queue.pendingCount || 0} pending, ${queue.parkedCount || 0} parked wait), exiting now (reason=${info?.reason || 'manual-request'})`,
231
+ );
232
+ }
233
+ try {
234
+ startShutdown({ action, reason: info?.reason || 'manual-request', requestedBy: info?.requestedBy || 'unknown', immediate, counts: queue });
235
+ } catch (error) {
236
+ log(`Relay ${action} failed to start: ${error?.message || error}`);
237
+ }
238
+ emit(true);
239
+ }
240
+
241
+ function evaluate() {
242
+ if (!request || isShuttingDown()) return;
243
+ const queue = counts();
244
+ // Only the processing turns hold it; pending and parked rows wait it out.
245
+ if (queuedShutdownCanProceed(queue)) {
246
+ fire();
247
+ return;
248
+ }
249
+ picture = readPicture(request.action);
250
+ const deadline = deadlineAt();
251
+ // A raised maximum (or none) moved the deadline into the future: the
252
+ // question no longer stands.
253
+ if (request.askedAt && (deadline === null || now() < deadline)) request.askedAt = null;
254
+ if (deadline !== null && !request.askedAt && now() >= deadline) {
255
+ request.askedAt = now();
256
+ const waitedMin = Math.round((now() - request.periodStartedAt) / 60_000);
257
+ log(`Relay ${request.action} has waited ${waitedMin} min for ${queue.processingCount} running turn${Number(queue.processingCount) === 1 ? '' : 's'}; asking instead of restarting`);
258
+ emit();
259
+ try { onMaxWaitReached?.(publicState()); } catch {}
260
+ return;
261
+ }
262
+ emit();
263
+ }
264
+
265
+ function ensurePolling() {
266
+ if (pollTimer || !request) return;
267
+ pollTimer = setIntervalImpl(() => evaluate(), pollMs);
268
+ pollTimer?.unref?.();
269
+ }
270
+
271
+ /**
272
+ * Queue a restart or shutdown, or with `immediate` start it at once,
273
+ * running turns or not. A restart asked for while a shutdown waits turns
274
+ * it into a restart.
275
+ */
276
+ function requestShutdown({ reason = 'manual-request', requestedBy = 'localhost-api', restart = false, immediate = false, expectRequestedAt = null } = {}) {
277
+ const safeReason = clampText(reason, 140, 'manual-request');
278
+ const safeRequestedBy = clampText(requestedBy, 80, 'localhost-api');
279
+ const restartRequested = normalizeRestartRequestFlag(restart);
280
+ const immediateRequested = normalizeRestartRequestFlag(immediate);
281
+ const requestedAction = restartRequested ? 'restart' : 'shutdown';
282
+ if (isShuttingDown()) {
283
+ return {
284
+ accepted: false,
285
+ status: 'shutting_down',
286
+ action: requestedAction,
287
+ restart: restartRequested,
288
+ reason: safeReason,
289
+ };
290
+ }
291
+ // "Restart now" from a banner or dialog names the request it saw: one
292
+ // that is gone (it fired, was cancelled, or the relay restarted since)
293
+ // is not a reason to restart now.
294
+ const expected = String(expectRequestedAt ?? '').trim();
295
+ if (immediateRequested && expected && (!request || iso(request.requestedAt) !== expected)) {
296
+ return { accepted: false, stale: true, ...publicState() };
297
+ }
298
+ const at = now();
299
+ if (!request) {
300
+ request = {
301
+ action: requestedAction,
302
+ reason: safeReason,
303
+ requestedBy: safeRequestedBy,
304
+ requestedAt: at,
305
+ periodStartedAt: at,
306
+ askedAt: null,
307
+ keptWaiting: 0,
308
+ };
309
+ } else if (restartRequested && request.action !== 'restart') {
310
+ request = { ...request, action: 'restart', reason: safeReason, requestedBy: safeRequestedBy };
311
+ }
312
+ // What it waits for and what it holds, read before a shutdown that
313
+ // starts now clears it: the caller's answer names both.
314
+ picture = readPicture(request.action);
315
+ const answer = {
316
+ accepted: true,
317
+ status: 'queued',
318
+ action: request.action,
319
+ restart: request.action === 'restart',
320
+ requestedAt: iso(request.requestedAt),
321
+ reason: request.reason,
322
+ requestedBy: request.requestedBy,
323
+ immediate: immediateRequested,
324
+ maxWaitMs: maxWaitMs(),
325
+ deadlineAt: iso(deadlineAt()),
326
+ waitingFor: picture.waitingFor,
327
+ held: picture.held,
328
+ backgroundTasks: picture.backgroundTasks,
329
+ survival: picture.survival,
330
+ };
331
+ if (immediateRequested) {
332
+ fire({ immediate: true, by: safeRequestedBy });
333
+ return { ...answer, status: 'shutting_down', queue: counts() };
334
+ }
335
+ evaluate();
336
+ if (request) {
337
+ ensurePolling();
338
+ emit(true);
339
+ } else {
340
+ answer.status = 'shutting_down';
341
+ }
342
+ return { ...answer, queue: counts() };
343
+ }
344
+
345
+ function cancel({ requestedBy = 'localhost-api' } = {}) {
346
+ if (isShuttingDown()) return { cancelled: false, ...publicState(), status: 'shutting_down' };
347
+ if (!request) return { cancelled: false, ...publicState() };
348
+ const dropped = { action: request.action, reason: request.reason, requestedBy: request.requestedBy, requestedAt: iso(request.requestedAt) };
349
+ request = null;
350
+ picture = EMPTY_PICTURE;
351
+ stopPolling();
352
+ log(`Relay ${dropped.action} request cancelled by ${clampText(requestedBy, 80, 'localhost-api')}`);
353
+ emit(true);
354
+ return { cancelled: true, dropped, ...publicState() };
355
+ }
356
+
357
+ /** Answer to the question: wait another full period, counted from now. */
358
+ function keepWaiting({ requestedBy = 'localhost-api' } = {}) {
359
+ // Only an answer to the question: without one there is nothing to extend.
360
+ if (isShuttingDown() || !request || !request.askedAt) return { extended: false, ...publicState() };
361
+ request.periodStartedAt = now();
362
+ request.askedAt = null;
363
+ request.keptWaiting += 1;
364
+ log(`Relay ${request.action} keeps waiting another ${Math.round(maxWaitMs() / 60_000)} min (by ${clampText(requestedBy, 80, 'localhost-api')})`);
365
+ evaluate();
366
+ emit(true);
367
+ return { extended: true, ...publicState() };
368
+ }
369
+
370
+ return {
371
+ request: requestShutdown,
372
+ cancel,
373
+ keepWaiting,
374
+ getState: publicState,
375
+ isQueued: () => !!request && !isShuttingDown(),
376
+ /** Re-check now (the setting changed): may fire or ask. Readers use getState. */
377
+ refresh() { evaluate(); return publicState(); },
378
+ dispose() { stopPolling(); },
379
+ };
380
+ }
@@ -11,7 +11,10 @@ export const RELAY_SHUTDOWN_QUEUED_REASON = 'relay_shutdown_queued';
11
11
  /** Whether the runtime holds a shutdown or restart request that has not started yet. */
12
12
  export function isRelayShutdownQueued(runtimeState) {
13
13
  if (!runtimeState) return false;
14
- if (runtimeState.relayShutdownQueued === true) return true;
14
+ // The runtime answers this from memory; only a state without the flag
15
+ // (older wiring, tests) reads the whole shutdown state, which counts the
16
+ // queue in the database.
17
+ if (typeof runtimeState.relayShutdownQueued === 'boolean') return runtimeState.relayShutdownQueued;
15
18
  return String(runtimeState.relayShutdown?.status || '') === 'queued';
16
19
  }
17
20