@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,133 @@
1
+ 'use strict';
2
+
3
+ import { backgroundTaskRowsTableProblem } from '../migrations/0018-background-task-rows.mjs';
4
+
5
+ // Persistence for the background-task store's console rows: the
6
+ // `background_task_rows` table (migration 0018) behind a small adapter, so
7
+ // the store restores its rows after a restart and stays testable without a
8
+ // database. Without a usable table every call is a no-op and the store keeps
9
+ // its rows in memory only, as before; a problem is logged once and never
10
+ // stops the relay.
11
+
12
+ function unavailable(reason) {
13
+ return {
14
+ available: false,
15
+ reason,
16
+ load: () => [],
17
+ saveLive: () => false,
18
+ saveEnded: () => false,
19
+ remove: () => false,
20
+ forget: () => false,
21
+ pruneEnded: () => 0,
22
+ touchLive: () => false,
23
+ };
24
+ }
25
+
26
+ function parseRow(text) {
27
+ try {
28
+ const row = JSON.parse(String(text || ''));
29
+ return row && typeof row === 'object' && !Array.isArray(row) ? row : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ /** A row as stored: no health reading (the poller reads the file again). */
36
+ function storedRowJson(row) {
37
+ const { health: _health, ...rest } = row || {};
38
+ return JSON.stringify(rest);
39
+ }
40
+
41
+ export function createSqliteBackgroundTaskPersistence(db, { logger = console, now = () => Date.now() } = {}) {
42
+ let stmts;
43
+ try {
44
+ const problem = backgroundTaskRowsTableProblem(db);
45
+ if (problem) return unavailable(problem);
46
+ stmts = {
47
+ list: db.prepare(`SELECT store_key, task_id, state, row_json, ended_at, updated_at FROM background_task_rows`),
48
+ touchLive: db.prepare(`UPDATE background_task_rows SET updated_at = ? WHERE store_key = ? AND state = 'live'`),
49
+ upsert: db.prepare(`
50
+ INSERT INTO background_task_rows (store_key, task_id, state, row_json, ended_at, updated_at)
51
+ VALUES (?, ?, ?, ?, ?, ?)
52
+ ON CONFLICT(store_key, task_id) DO UPDATE SET
53
+ state = excluded.state, row_json = excluded.row_json, ended_at = excluded.ended_at, updated_at = excluded.updated_at
54
+ `),
55
+ liveOfKey: db.prepare(`SELECT task_id FROM background_task_rows WHERE store_key = ? AND state = 'live'`),
56
+ remove: db.prepare(`DELETE FROM background_task_rows WHERE store_key = ? AND task_id = ?`),
57
+ removeLive: db.prepare(`DELETE FROM background_task_rows WHERE store_key = ? AND task_id = ? AND state = 'live'`),
58
+ forget: db.prepare(`DELETE FROM background_task_rows WHERE store_key = ?`),
59
+ prune: db.prepare(`DELETE FROM background_task_rows WHERE state = 'ended' AND ended_at < ?`),
60
+ };
61
+ } catch (error) {
62
+ const reason = error?.message || String(error);
63
+ logger?.warn?.(`[background-tasks] console rows are not persisted: ${reason}`);
64
+ return unavailable(reason);
65
+ }
66
+
67
+ // One warning per kind of failure: a write that fails keeps failing, and
68
+ // the store goes on in memory either way.
69
+ const warned = new Set();
70
+ function guard(what, run, fallback) {
71
+ try {
72
+ return run();
73
+ } catch (error) {
74
+ if (!warned.has(what)) {
75
+ warned.add(what);
76
+ logger?.warn?.(`[background-tasks] could not ${what} a console row: ${error?.message || error}`);
77
+ }
78
+ return fallback;
79
+ }
80
+ }
81
+
82
+ const saveLiveTx = db.transaction((key, rows) => {
83
+ const keep = new Set(rows.map((row) => String(row.taskId)));
84
+ for (const { task_id: taskId } of stmts.liveOfKey.all(key)) {
85
+ if (!keep.has(taskId)) stmts.removeLive.run(key, taskId);
86
+ }
87
+ const at = now();
88
+ for (const row of rows) stmts.upsert.run(key, String(row.taskId), 'live', storedRowJson(row), null, at);
89
+ });
90
+
91
+ return {
92
+ available: true,
93
+ reason: '',
94
+ /** Every stored row: `{ key, taskId, state, row, endedAt, updatedAt }`. */
95
+ load() {
96
+ return guard('read', () => stmts.list.all().map((entry) => ({
97
+ key: String(entry.store_key),
98
+ taskId: String(entry.task_id),
99
+ state: String(entry.state),
100
+ row: parseRow(entry.row_json),
101
+ endedAt: Number.isFinite(Number(entry.ended_at)) && entry.ended_at !== null ? Number(entry.ended_at) : null,
102
+ updatedAt: Number.isFinite(Number(entry.updated_at)) ? Number(entry.updated_at) : null,
103
+ })).filter((entry) => entry.row), []);
104
+ },
105
+ /** The key's live console rows are exactly these (an ended row of the same task is replaced). */
106
+ saveLive(key, rows) {
107
+ return guard('write', () => { saveLiveTx(String(key), Array.isArray(rows) ? rows : []); return true; }, false);
108
+ },
109
+ saveEnded(key, row) {
110
+ return guard('write', () => {
111
+ stmts.upsert.run(String(key), String(row.taskId), 'ended', storedRowJson(row), Number(row.endedAt) || now(), now());
112
+ return true;
113
+ }, false);
114
+ },
115
+ remove(key, taskId) {
116
+ return guard('delete', () => stmts.remove.run(String(key), String(taskId)).changes > 0, false);
117
+ },
118
+ forget(keys) {
119
+ return guard('delete', () => {
120
+ for (const key of keys) stmts.forget.run(String(key));
121
+ return true;
122
+ }, false);
123
+ },
124
+ /** The key's live rows were confirmed running now (its worker's digest agreed). */
125
+ touchLive(key) {
126
+ return guard('write', () => stmts.touchLive.run(now(), String(key)).changes > 0, false);
127
+ },
128
+ /** Drops the ended rows that ended before `cutoff` (epoch ms). */
129
+ pruneEnded(cutoff) {
130
+ return guard('prune', () => stmts.prune.run(Number(cutoff) || 0).changes, 0);
131
+ },
132
+ };
133
+ }
@@ -22,6 +22,17 @@
22
22
  * Health: the health poller's last reading per live task (`setHealth`), served
23
23
  * with the task rows (`withHealth`) and carried onto the ended row, where the
24
24
  * poller's reading after the end replaces it (`setEndedHealth`).
25
+ *
26
+ * Across a restart (`persistence`, the `background_task_rows` table): the
27
+ * console rows survive, the live sets do not. Ended rows come back as they
28
+ * were, for the rest of their hour. A live task with a console comes back
29
+ * held (`restored`): its console stays readable, but no panel, state or
30
+ * suspend check counts it as running, because only the worker can say that.
31
+ * The worker's next set settles it: a task in the set is live again, one not
32
+ * in it ended while the relay was away. A worker that never reports (it died
33
+ * with the relay) has its held tasks marked ended (`endUnconfirmed`), never
34
+ * running again. Health readings are not stored: the poller reads the files
35
+ * again. Sub-agent rows have no console and are not stored either.
25
36
  */
26
37
 
27
38
  import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
@@ -52,10 +63,29 @@ function exitCodeOf(value) {
52
63
  return Number.isInteger(number) ? number : null;
53
64
  }
54
65
 
66
+ // A stored live row is confirmed at most this often while its worker's
67
+ // digest agrees (its `updated_at`), and one not confirmed for this long is a
68
+ // leftover of a worker that went away without a word: not restored.
69
+ const LIVE_TOUCH_EVERY_MS = 30 * 60_000;
70
+ const LIVE_STALE_MS = 6 * 60 * 60_000;
71
+
72
+ // What of a live console task is stored: what the console and an ended row
73
+ // need, not what changes with every progress post.
74
+ const STORED_LIVE_FIELDS = ['taskId', 'taskType', 'description', 'command', 'startedAt', 'outputPath', 'outputKind', 'stoppable', 'toolUseId'];
75
+
76
+ function storedLiveRow(task, ahead) {
77
+ const row = {};
78
+ for (const field of STORED_LIVE_FIELDS) if (task?.[field] !== undefined && task?.[field] !== null) row[field] = task[field];
79
+ // How it ended, told before the set dropped it.
80
+ if (ahead) row.settled = { status: ahead.status, exitCode: ahead.exitCode, ...(ahead.steps !== undefined ? { steps: ahead.steps } : {}) };
81
+ return row;
82
+ }
83
+
55
84
  export function createBackgroundTaskStore({
56
85
  now = () => Date.now(),
57
86
  endedKeepMs = TASK_OUTPUT_LIMITS.endedKeepMs,
58
87
  endedMax = TASK_OUTPUT_LIMITS.endedMax,
88
+ persistence = null,
59
89
  } = {}) {
60
90
  const sets = new Map();
61
91
  // key → Map(taskId → ended row), newest last.
@@ -75,6 +105,18 @@ export function createBackgroundTaskStore({
75
105
  const holders = new Map();
76
106
  // Keys whose pages were told of the placeholder for unreported work.
77
107
  const announced = new Set();
108
+ // key → Map(taskId → row): live console tasks of the runtime before, held
109
+ // until the worker's set says what became of them.
110
+ const restored = new Map();
111
+ // key → what was stored of its live console tasks last, to write only on change.
112
+ const storedLive = new Map();
113
+ // key → when its stored live rows were last written or confirmed.
114
+ const touchedAt = new Map();
115
+ // key → Map(taskId → when the runtime before last knew the held task running).
116
+ const heldSince = new Map();
117
+ const store = persistence && persistence.available !== false ? persistence : null;
118
+
119
+ restore();
78
120
 
79
121
  return {
80
122
  sets,
@@ -97,9 +139,33 @@ export function createBackgroundTaskStore({
97
139
  health: healthByKey.get(id)?.get(taskId) || task.health || undefined,
98
140
  });
99
141
  }
142
+ // Tasks held since the restart: the worker's set says which still run
143
+ // (their console is the one they had; the first path a task names
144
+ // wins), and the others ended while the relay was away.
145
+ const held = restored.get(id);
146
+ if (held) {
147
+ restored.delete(id);
148
+ for (const [taskId, row] of held) {
149
+ const live = next.find((task) => keyOf(task?.taskId) === taskId);
150
+ if (live) {
151
+ if (row.outputPath && live.outputPath !== row.outputPath) {
152
+ live.outputPath = row.outputPath;
153
+ live.outputKind = row.outputKind;
154
+ }
155
+ if (row.settled && !settledAhead.get(id)?.has(taskId)) {
156
+ let ahead = settledAhead.get(id);
157
+ if (!ahead) settledAhead.set(id, ahead = new Map());
158
+ ahead.set(taskId, { ...row.settled, at: now() });
159
+ }
160
+ continue;
161
+ }
162
+ endRestored(id, row);
163
+ }
164
+ heldSince.delete(id);
165
+ }
100
166
  // A task back in the set (a resync after a restart) is not ended.
101
167
  for (const taskId of nextIds) {
102
- endedRows.get(id)?.delete(taskId);
168
+ if (endedRows.get(id)?.delete(taskId)) store?.remove(id, taskId);
103
169
  settledAhead.get(id)?.delete(taskId);
104
170
  }
105
171
  const health = healthByKey.get(id);
@@ -107,11 +173,9 @@ export function createBackgroundTaskStore({
107
173
  for (const taskId of [...health.keys()]) if (!nextIds.has(taskId)) health.delete(taskId);
108
174
  if (!health.size) healthByKey.delete(id);
109
175
  }
110
- if (next.length === 0) {
111
- sets.delete(id);
112
- return;
113
- }
114
- sets.set(id, { tasks: next, updatedAt: now() });
176
+ if (next.length === 0) sets.delete(id);
177
+ else sets.set(id, { tasks: next, updatedAt: now() });
178
+ storeLive(id);
115
179
  },
116
180
  /**
117
181
  * How a task ended (`/api/background-task-settled`): updates its ended
@@ -119,7 +183,7 @@ export function createBackgroundTaskStore({
119
183
  * runtime never saw live that names its output, becomes an ended row of
120
184
  * its own. Returns the ended row when there is one.
121
185
  */
122
- settle(conversationId, taskId, { status, exitCode, outputPath, description, taskType, startedAt, outputKind, steps } = {}) {
186
+ settle(conversationId, taskId, { status, exitCode, outputPath, description, command, taskType, startedAt, outputKind, steps } = {}) {
123
187
  const id = keyOf(conversationId);
124
188
  const task = keyOf(taskId);
125
189
  if (!id || !task) return null;
@@ -136,8 +200,21 @@ export function createBackgroundTaskStore({
136
200
  if (existing.outputKind !== 'file' && existing.outputKind !== 'spool') existing.outputKind = outputKind === 'spool' ? 'spool' : 'file';
137
201
  }
138
202
  if (stepCount !== undefined) existing.steps = stepCount;
203
+ store?.saveEnded(id, existing);
139
204
  return existing;
140
205
  }
206
+ // A task held since the restart that ended while the relay was away
207
+ // (the worker sends the settle again): it is not running any more.
208
+ const heldRow = restored.get(id)?.get(task);
209
+ if (heldRow) {
210
+ restored.get(id).delete(task);
211
+ if (!restored.get(id).size) restored.delete(id);
212
+ return endRestored(id, {
213
+ ...heldRow,
214
+ settled: { status: normalized, exitCode: code, ...(stepCount !== undefined ? { steps: stepCount } : {}) },
215
+ ...(!heldRow.outputPath && outputPath ? { outputPath, outputKind: outputKind === 'spool' ? 'spool' : 'file' } : {}),
216
+ });
217
+ }
141
218
  const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
142
219
  if (live) {
143
220
  let ahead = settledAhead.get(id);
@@ -149,6 +226,7 @@ export function createBackgroundTaskStore({
149
226
  live.outputPath = outputPath;
150
227
  if (live.outputKind !== 'file' && live.outputKind !== 'spool') live.outputKind = outputKind === 'spool' ? 'spool' : 'file';
151
228
  }
229
+ storeLive(id);
152
230
  return null;
153
231
  }
154
232
  if (!outputPath) return null;
@@ -156,6 +234,7 @@ export function createBackgroundTaskStore({
156
234
  taskId: task,
157
235
  taskType: keyOf(taskType) || 'local_bash',
158
236
  description: String(description || '').trim().slice(0, 500),
237
+ ...(command ? { command } : {}),
159
238
  startedAt: Number(startedAt) || null,
160
239
  outputPath,
161
240
  outputKind: outputKind || 'file',
@@ -202,6 +281,7 @@ export function createBackgroundTaskStore({
202
281
  setEndedHealth(conversationId, taskId, value) {
203
282
  const row = endedRows.get(keyOf(conversationId))?.get(keyOf(taskId));
204
283
  if (!row || !value) return false;
284
+ // Not stored: the poller reads the file again after a restart.
205
285
  row.health = value;
206
286
  return true;
207
287
  },
@@ -234,6 +314,15 @@ export function createBackgroundTaskStore({
234
314
  const row = endedRows.get(id)?.get(task);
235
315
  if (row) return { key: id, task: { ...row }, ended: true };
236
316
  }
317
+ // Held since the restart: its console is readable while the worker
318
+ // has not reported yet.
319
+ for (const id of ids) {
320
+ const row = restored.get(id)?.get(task);
321
+ if (row) {
322
+ const { settled: _settled, ...rest } = row;
323
+ return { key: id, task: { ...rest }, ended: false, restored: true };
324
+ }
325
+ }
237
326
  return null;
238
327
  },
239
328
  get(conversationId) {
@@ -249,8 +338,20 @@ export function createBackgroundTaskStore({
249
338
  * they agree. Agreement counts as a report, an empty set included.
250
339
  */
251
340
  matches(digest, ...keys) {
341
+ // Tasks held since the restart are settled only by a set the worker
342
+ // sends: until then the worker is asked for it, whatever its digest.
343
+ if (keys.map(keyOf).some((key) => key && restored.has(key))) return false;
252
344
  const same = sameBackgroundTaskDigest(digest, this.digestFor(...keys));
253
- if (same) for (const key of keys.map(keyOf).filter(Boolean)) reported.add(key);
345
+ if (same) {
346
+ for (const key of keys.map(keyOf).filter(Boolean)) {
347
+ reported.add(key);
348
+ // Its stored rows are still running: say so now and then, so a
349
+ // restart can tell them from a dead worker's leftovers.
350
+ if (store && storedLive.get(key) && now() - (touchedAt.get(key) || 0) >= LIVE_TOUCH_EVERY_MS) {
351
+ if (store.touchLive?.(key)) touchedAt.set(key, now());
352
+ }
353
+ }
354
+ }
254
355
  return same;
255
356
  },
256
357
  /** Whether this runtime has heard from the worker about its tasks. */
@@ -305,7 +406,40 @@ export function createBackgroundTaskStore({
305
406
  else announced.delete(id);
306
407
  return true;
307
408
  },
308
- /** The conversation is gone: everything held for these keys goes. */
409
+ /**
410
+ * Read only: every task held since the restart, as `{ key, task }`
411
+ * (the stored row). Not running for any panel or check until the
412
+ * worker's set confirms it.
413
+ */
414
+ listRestored() {
415
+ const rows = [];
416
+ for (const [key, held] of restored) {
417
+ for (const row of held.values()) {
418
+ const { settled, ...task } = row;
419
+ rows.push({ key, task });
420
+ }
421
+ }
422
+ return rows;
423
+ },
424
+ /** Whether tasks of these keys are held since the restart. */
425
+ hasRestored(...keys) {
426
+ return keys.map(keyOf).some((key) => key && restored.has(key));
427
+ },
428
+ /**
429
+ * The workers that did not report since the restart: their held tasks
430
+ * are marked ended (how is unknown), never running again. Returns the
431
+ * keys whose ended rows changed.
432
+ */
433
+ endUnconfirmed() {
434
+ const changed = [];
435
+ for (const [id, held] of [...restored]) {
436
+ restored.delete(id);
437
+ for (const row of held.values()) endRestored(id, row);
438
+ storeLive(id);
439
+ changed.push(id);
440
+ }
441
+ return changed;
442
+ },
309
443
  /**
310
444
  * Drops expired ended rows and pending settles of every key, also of
311
445
  * conversations nobody reads again (the per-key prune runs on reads).
@@ -313,9 +447,17 @@ export function createBackgroundTaskStore({
313
447
  */
314
448
  pruneAll() {
315
449
  for (const id of new Set([...endedRows.keys(), ...settledAhead.keys()])) prune(id);
450
+ store?.pruneEnded(now() - endedKeepMs);
316
451
  },
452
+ /** The conversation is gone: everything held for these keys goes. */
317
453
  forget(...keys) {
318
- for (const id of keys.map(keyOf).filter(Boolean)) {
454
+ const ids = keys.map(keyOf).filter(Boolean);
455
+ if (ids.length) store?.forget(ids);
456
+ for (const id of ids) {
457
+ restored.delete(id);
458
+ heldSince.delete(id);
459
+ touchedAt.delete(id);
460
+ storedLive.delete(id);
319
461
  sets.delete(id);
320
462
  reported.delete(id);
321
463
  applied.delete(id);
@@ -328,6 +470,86 @@ export function createBackgroundTaskStore({
328
470
  },
329
471
  };
330
472
 
473
+ // The rows the runtime before kept: ended rows for the rest of their
474
+ // hour, live console tasks held for the worker's word.
475
+ function restore() {
476
+ if (!store) return;
477
+ const cutoff = now() - endedKeepMs;
478
+ const ended = [];
479
+ for (const entry of store.load()) {
480
+ const id = keyOf(entry.key);
481
+ const taskId = keyOf(entry.taskId);
482
+ if (!id || !taskId) continue;
483
+ if (entry.state === 'ended') {
484
+ const endedAt = Number(entry.endedAt ?? entry.row.endedAt);
485
+ if (!Number.isFinite(endedAt) || endedAt < cutoff) continue;
486
+ ended.push({ id, row: { ...entry.row, taskId, endedAt, stoppable: false } });
487
+ continue;
488
+ }
489
+ if (entry.state !== 'live') continue;
490
+ // Not confirmed for hours: its worker went away in a runtime before
491
+ // without a word. Nothing to show, now or as an ended row.
492
+ const since = Number(entry.updatedAt);
493
+ if (!Number.isFinite(since) || now() - since > LIVE_STALE_MS) {
494
+ store.remove(id, taskId);
495
+ continue;
496
+ }
497
+ let held = restored.get(id);
498
+ if (!held) restored.set(id, held = new Map());
499
+ held.set(taskId, { ...entry.row, taskId });
500
+ let sinceOf = heldSince.get(id);
501
+ if (!sinceOf) heldSince.set(id, sinceOf = new Map());
502
+ sinceOf.set(taskId, Math.min(since, now()));
503
+ storedLive.set(id, null);
504
+ }
505
+ // Oldest first, as `addEnded` keeps them.
506
+ ended.sort((a, b) => a.row.endedAt - b.row.endedAt);
507
+ for (const { id, row } of ended) {
508
+ let rows = endedRows.get(id);
509
+ if (!rows) endedRows.set(id, rows = new Map());
510
+ rows.set(row.taskId, row);
511
+ while (rows.size > endedMax) rows.delete(rows.keys().next().value);
512
+ }
513
+ store.pruneEnded(cutoff);
514
+ }
515
+
516
+ // A held task that is not running any more becomes an ended row, with how
517
+ // it ended when the runtime before was told.
518
+ // It ended at the latest when the runtime before last knew it running, so
519
+ // that is its end time: a leftover from long ago does not count as fresh.
520
+ function endRestored(id, row) {
521
+ const { settled, ...task } = row;
522
+ const since = heldSince.get(id)?.get(keyOf(task.taskId));
523
+ heldSince.get(id)?.delete(keyOf(task.taskId));
524
+ if (!heldSince.get(id)?.size) heldSince.delete(id);
525
+ return addEnded(id, {
526
+ ...task,
527
+ ...(settled?.steps !== undefined ? { steps: settled.steps } : {}),
528
+ status: settled?.status || 'unknown',
529
+ exitCode: settled ? settled.exitCode : null,
530
+ }, { endedAt: Number.isFinite(since) ? since : undefined });
531
+ }
532
+
533
+ // Stores the key's live console tasks when they changed (what is stored,
534
+ // not every progress post).
535
+ function storeLive(id) {
536
+ if (!store) return;
537
+ const ahead = settledAhead.get(id);
538
+ const rows = (sets.get(id)?.tasks || [])
539
+ .filter((task) => task?.outputPath && keyOf(task?.taskId))
540
+ .map((task) => storedLiveRow(task, ahead?.get(keyOf(task.taskId))));
541
+ // Held tasks stay stored until the worker's set settles them.
542
+ for (const row of restored.get(id)?.values() || []) rows.push(row);
543
+ const signature = JSON.stringify(rows);
544
+ if (storedLive.get(id) === signature) return;
545
+ if (!rows.length && !storedLive.has(id)) return;
546
+ if (store.saveLive(id, rows)) {
547
+ touchedAt.set(id, now());
548
+ if (rows.length) storedLive.set(id, signature);
549
+ else storedLive.delete(id);
550
+ }
551
+ }
552
+
331
553
  function prune(id) {
332
554
  const cutoff = now() - endedKeepMs;
333
555
  const rows = endedRows.get(id);
@@ -342,7 +564,7 @@ export function createBackgroundTaskStore({
342
564
  }
343
565
  }
344
566
 
345
- function addEnded(id, task) {
567
+ function addEnded(id, task, { endedAt = now() } = {}) {
346
568
  let rows = endedRows.get(id);
347
569
  if (!rows) endedRows.set(id, rows = new Map());
348
570
  const taskId = keyOf(task.taskId);
@@ -352,13 +574,18 @@ export function createBackgroundTaskStore({
352
574
  taskId,
353
575
  status: normalizeEndedTaskStatus(task.status, exitCodeOf(task.exitCode)),
354
576
  exitCode: exitCodeOf(task.exitCode),
355
- endedAt: now(),
577
+ endedAt,
356
578
  stoppable: false,
357
579
  };
358
580
  if (!row.health) delete row.health;
359
581
  rows.set(taskId, row);
360
582
  settledAhead.get(id)?.delete(taskId);
361
- while (rows.size > endedMax) rows.delete(rows.keys().next().value);
583
+ store?.saveEnded(id, row);
584
+ while (rows.size > endedMax) {
585
+ const oldest = rows.keys().next().value;
586
+ rows.delete(oldest);
587
+ store?.remove(id, oldest);
588
+ }
362
589
  prune(id);
363
590
  return row;
364
591
  }
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ // What a send to an existing conversation runs with when the request names no
4
+ // model or no effort. The answer is the conversation's own choice, never the
5
+ // relay-wide catalog default: a plain POST /api/message from a script or an
6
+ // agent used to switch a Copilot conversation to the catalog's first model and
7
+ // to effort 'none', and then saved that as the conversation's preference.
8
+ //
9
+ // The effort clamp is the composer's own (public/app/conversation-preferences.mjs),
10
+ // so the relay and the page settle on the same level for the same model.
11
+ import { nearestSupportedReasoningEffort } from '../public/app/conversation-preferences.mjs';
12
+
13
+ const AUTO_MODEL = 'auto';
14
+
15
+ function toText(value) {
16
+ return String(value ?? '').trim();
17
+ }
18
+
19
+ /**
20
+ * The model an existing conversation was last set to, in this order:
21
+ * 1. the conversation's saved preference (what the composer shows);
22
+ * 2. the runtime session's model (provider_model, else model);
23
+ * 3. the model of the latest user message.
24
+ * 'auto' is not a model a later turn can run on, so it is skipped. Null when
25
+ * the conversation has none of these: the caller falls back to the catalog.
26
+ */
27
+ export function inheritedConversationModel({
28
+ conversation = null,
29
+ runtimeSession = null,
30
+ latestUserMessageModel = '',
31
+ } = {}) {
32
+ // provider_model and model are looked at one by one, so an 'auto' in one
33
+ // does not hide a real model in the other.
34
+ const candidates = [
35
+ { source: 'conversation', model: toText(conversation?.preferred_model) },
36
+ { source: 'runtime', model: toText(runtimeSession?.provider_model) },
37
+ { source: 'runtime', model: toText(runtimeSession?.model) },
38
+ { source: 'message', model: toText(latestUserMessageModel) },
39
+ ];
40
+ return candidates.find((entry) => entry.model && entry.model.toLowerCase() !== AUTO_MODEL) || null;
41
+ }
42
+
43
+ /**
44
+ * The effort for a turn that names none: the first candidate the model
45
+ * supports (or the nearest level to it), else the model's lowest level that
46
+ * is not 'none'. 'none' comes back only when it is the only level, or when a
47
+ * candidate asks for it and the model has it. '' when the model lists no
48
+ * levels at all. A provider whose own default is better than "the lowest
49
+ * level" passes `fallback`, returned when no candidate matches or clamps.
50
+ */
51
+ export function fallbackReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
52
+ return nearestSupportedReasoningEffort(
53
+ (Array.isArray(candidates) ? candidates : [candidates]).map(toText).filter(Boolean),
54
+ supportedEfforts,
55
+ { fallback },
56
+ );
57
+ }
@@ -14,6 +14,7 @@ import { windowsSystemToolPath } from '../../shared/windows-system-tools.mjs';
14
14
  export function runHostSuspendToRam({
15
15
  platform = process.platform,
16
16
  spawnImpl = spawn,
17
+ systemToolPath = windowsSystemToolPath,
17
18
  dryRun = /^(1|true|yes|on)$/i.test(String(process.env.OAR_HOST_SUSPEND_DRY_RUN || '')),
18
19
  logger = console,
19
20
  } = {}) {
@@ -26,7 +27,7 @@ export function runHostSuspendToRam({
26
27
  return { ok: true, command, dryRun: true };
27
28
  }
28
29
  try {
29
- const child = spawnImpl(windowsSystemToolPath('rundll32.exe'), ['powrprof.dll,SetSuspendState', '0,0,0'], {
30
+ const child = spawnImpl(systemToolPath('rundll32.exe'), ['powrprof.dll,SetSuspendState', '0,0,0'], {
30
31
  detached: true,
31
32
  stdio: 'ignore',
32
33
  windowsHide: true,
@@ -261,6 +261,10 @@ export function createPreviewProxyServer({
261
261
  // open HMR connection would therefore hang shutdown forever unless the lane
262
262
  // tracks the pairs itself.
263
263
  const upgradedSockets = new Set();
264
+ // Upgrades still waiting on the dev server's answer, by their teardown: the
265
+ // client socket is detached from the http.Server already, so it would hold
266
+ // close() open just the same.
267
+ const pendingUpgrades = new Set();
264
268
 
265
269
  function trackUpgradedSocket(socket) {
266
270
  if (!socket) return;
@@ -411,14 +415,20 @@ export function createPreviewProxyServer({
411
415
  let upstreamReq = null;
412
416
  let upstreamSocket = null;
413
417
  const teardown = () => {
418
+ pendingUpgrades.delete(teardown);
414
419
  try { socket.destroy(); } catch {}
415
420
  try { upstreamSocket?.destroy(); } catch {}
416
421
  try { upstreamReq?.destroy(); } catch {}
417
422
  };
423
+ pendingUpgrades.add(teardown);
418
424
  socket.on('error', teardown);
419
425
  // A client that leaves before the dev server answers abandons the
420
426
  // handshake. Once both sides are piped, an orderly close travels through
421
- // the pipes, so the pending writes still reach the other side.
427
+ // the pipes, so the pending writes still reach the other side. Leaving
428
+ // can arrive as a bare FIN: the server's sockets allow half-open
429
+ // connections, so 'end' alone never closes this one, and nothing reads
430
+ // it again to notice a reset that follows the FIN.
431
+ socket.once('end', teardown);
422
432
  socket.once('close', teardown);
423
433
  const reply = (text) => {
424
434
  if (socket.destroyed) return;
@@ -477,7 +487,9 @@ export function createPreviewProxyServer({
477
487
  reply(`${lines.join('\r\n')}\r\n\r\n`);
478
488
  if (upstreamHead?.length) reply(upstreamHead);
479
489
  if (head?.length) upstreamSocket.write(head);
490
+ socket.removeListener('end', teardown);
480
491
  socket.removeListener('close', teardown);
492
+ pendingUpgrades.delete(teardown);
481
493
  trackUpgradedSocket(socket);
482
494
  trackUpgradedSocket(upstreamSocket);
483
495
  upstreamSocket.pipe(socket);
@@ -527,6 +539,7 @@ export function createPreviewProxyServer({
527
539
  try { socket.destroy(); } catch {}
528
540
  }
529
541
  upgradedSockets.clear();
542
+ for (const teardown of [...pendingUpgrades]) teardown();
530
543
  return new Promise((resolve) => {
531
544
  if (!server) return resolve();
532
545
  const current = server;
@@ -67,10 +67,11 @@ export function createProcessLister({
67
67
  readdirSyncImpl = fs.readdirSync,
68
68
  readFileSyncImpl = fs.readFileSync,
69
69
  execFileImpl = execFile,
70
+ systemToolPath,
70
71
  inspector = null,
71
72
  } = {}) {
72
73
  if (platform === 'win32') {
73
- const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl });
74
+ const windows = inspector || createSessionWorkerProcessInspector({ platform, execFileImpl, systemToolPath });
74
75
  return {
75
76
  // A child keeps the pid of its parent after the parent has ended.
76
77
  orphansKeepParent: true,