@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
@@ -27,7 +27,9 @@ import {
27
27
  import { schemaFields } from '../../shared/question-schema.mjs';
28
28
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
29
29
  import { RELAY_EVENT_DEFAULT_TYPES } from '../../shared/relay-events.mjs';
30
+ import { checkRequestedProviderModel, isAutoModelId, providerModelLabel, unknownProviderModelText } from './provider-model-selection.mjs';
30
31
  import { cleanTaskOutput, parseRunnerSummary, stallMinutes, stripSgr } from '../../shared/task-output-text.mjs';
32
+ import { TASK_OUTPUT_NONE_MESSAGES } from '../public/app/task-console-render.mjs';
31
33
  import {
32
34
  RELAY_EVENT_REMOTE_PULL_PATH,
33
35
  RELAY_EVENT_REMOTE_SUBSCRIBE_PATH,
@@ -130,6 +132,8 @@ const FAILURE_TEXT_PATTERNS = Object.freeze([
130
132
  /^relay timeout after \d+ attempts/i,
131
133
  ]);
132
134
  const CLAUDE_LONG_CONTEXT_SUFFIX = /\[1m\]$/i;
135
+ // How many of a provider's model ids an unknown-model refusal carries.
136
+ const UNKNOWN_MODEL_LIST_MAX = 50;
133
137
  // POST /api/conversation/bootstrap refuses an effort its model does not take
134
138
  // with this code (400); POST /api/message sends the same 400 without a code,
135
139
  // but with the list below.
@@ -269,6 +273,45 @@ function matchOfferedModel(candidate, offered = []) {
269
273
  return offered.find((model) => model.toLowerCase().replace(CLAUDE_LONG_CONTEXT_SUFFIX, '') === wantedBase) || '';
270
274
  }
271
275
 
276
+ /** The models /api/models lists for a provider label ("openai-byok"). */
277
+ function catalogModelsLabelled(catalog, label) {
278
+ const providersByModel = isPlainObject(catalog?.models?.providersByModel) ? catalog.models.providersByModel : {};
279
+ return Object.entries(providersByModel)
280
+ .filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
281
+ .map(([model]) => model);
282
+ }
283
+
284
+ /** Copilot's catalogue: every model of /api/models no other provider claims alone. */
285
+ function catalogGithubModels(catalog) {
286
+ const models = catalog?.models || {};
287
+ const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
288
+ const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
289
+ return catalogModels.filter((model) => {
290
+ const providers = catalogProvidersFor(providersByModel, model);
291
+ return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
292
+ });
293
+ }
294
+
295
+ /**
296
+ * The models a provider's New Chat list offers on the target, which its
297
+ * bootstrap and message routes accept, or null when the target has no list
298
+ * for it yet (discovery has not run or failed, the route did not answer):
299
+ * then a requested model cannot be checked here.
300
+ */
301
+ function offeredModelList(catalog, provider) {
302
+ if (provider === 'github') {
303
+ const listed = catalogGithubModels(catalog);
304
+ return listed.length ? listed : null;
305
+ }
306
+ if (provider === 'openai') {
307
+ const listed = catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai);
308
+ return listed.length ? listed : null;
309
+ }
310
+ const settings = catalog?.settings?.[provider];
311
+ const listed = Array.isArray(settings?.models) ? settings.models.map(toText).filter(Boolean) : [];
312
+ return listed.length ? listed : null;
313
+ }
314
+
272
315
  /** A reasoning effort as the relays write it, or '' when the value is not one. */
273
316
  function normalizeEffort(value) {
274
317
  const effort = toText(value).toLowerCase();
@@ -1016,19 +1059,9 @@ export function createRemoteRelayDispatcher({
1016
1059
 
1017
1060
  function listProviders(catalog) {
1018
1061
  const models = catalog.models || {};
1019
- const providersByModel = isPlainObject(models.providersByModel) ? models.providersByModel : {};
1020
- const catalogModels = Array.isArray(models.models) ? models.models.map(toText).filter(Boolean) : [];
1021
- const modelsLabelled = (label) => Object.entries(providersByModel)
1022
- .filter(([, providers]) => Array.isArray(providers) && providers.some((entry) => toText(entry).toLowerCase() === label))
1023
- .map(([model]) => model);
1024
-
1025
1062
  return REMOTE_RELAY_PROVIDERS.map((provider) => {
1026
1063
  if (provider === 'github') {
1027
- // Copilot's catalogue is every model no other provider claims alone.
1028
- const githubModels = catalogModels.filter((model) => {
1029
- const providers = catalogProvidersFor(providersByModel, model);
1030
- return providers.length === 0 || providers.includes(CATALOG_PROVIDER_LABELS.github);
1031
- });
1064
+ const githubModels = catalogGithubModels(catalog);
1032
1065
  const defaultModel = toText(catalog.status.defaultModel) || toText(models.defaultModel) || toText(models.currentModel) || null;
1033
1066
  return {
1034
1067
  provider,
@@ -1060,7 +1093,7 @@ export function createRemoteRelayDispatcher({
1060
1093
  && (provider === 'claude' || provider === 'grok' || settings.configured === true);
1061
1094
  const defaultModel = toText(settings.model) || null;
1062
1095
  const listed = provider === 'openai'
1063
- ? modelsLabelled(CATALOG_PROVIDER_LABELS.openai)
1096
+ ? catalogModelsLabelled(catalog, CATALOG_PROVIDER_LABELS.openai)
1064
1097
  : (Array.isArray(settings.models) ? settings.models : []);
1065
1098
  return {
1066
1099
  provider,
@@ -1299,9 +1332,12 @@ export function createRemoteRelayDispatcher({
1299
1332
  const health = isPlainObject(task.health) ? task.health : null;
1300
1333
  const quietAfter = Number(row.taskStallMinutes) || null;
1301
1334
  const minutes = live && health ? stallMinutes(health.lastOutputAt, now()) : null;
1335
+ // The command line comes scrubbed: the relay cleans it when the worker posts it.
1336
+ const command = toText(task.command);
1302
1337
  return {
1303
1338
  taskId,
1304
1339
  output: kind !== 'none',
1340
+ ...(command ? { command: clipLine(command, 500) } : {}),
1305
1341
  ...(health ? {
1306
1342
  health: {
1307
1343
  ...(minutes !== null ? { minutesSinceOutput: minutes } : {}),
@@ -1595,7 +1631,7 @@ export function createRemoteRelayDispatcher({
1595
1631
  }
1596
1632
  const kind = toText(answer?.kind) || 'file';
1597
1633
  let note = null;
1598
- if (kind === 'none') note = toText(answer?.message) || 'Output is not available for this provider.';
1634
+ if (kind === 'none') note = toText(answer?.message) || TASK_OUTPUT_NONE_MESSAGES.provider;
1599
1635
  else if (answer?.pending) note = 'The session\'s worker was asked for the output; call again in a few seconds.';
1600
1636
  else if (answer?.unavailable) note = 'The output file is not there any more (the provider cleaned it up).';
1601
1637
  else if (kind === 'snapshot') note = 'Only the end of this task\'s output is available (its provider keeps no file).';
@@ -1785,16 +1821,24 @@ export function createRemoteRelayDispatcher({
1785
1821
  }
1786
1822
  const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1787
1823
  const provider = toText(runtime.providerType).toLowerCase() || 'github';
1824
+ // Checked before any approval card (runRelayAction) when the session
1825
+ // could be read there; here otherwise.
1826
+ const settled = args.model
1827
+ ? (ctx.settledModel || await settleSendModel(ctx, conversation, args.model))
1828
+ : null;
1829
+ const requestedModel = settled?.model || '';
1830
+ const modelNote = settled?.note || null;
1788
1831
  // Without a model the remote falls back to its catalogue's current one and
1789
1832
  // silently switches the session; keep the model the session already uses.
1790
1833
  // Grok pins its model at bootstrap and refuses any other, so it gets none.
1791
- const model = args.model
1834
+ const model = requestedModel
1792
1835
  || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
1793
1836
  const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
1794
1837
  // The remote runs a prompt that names no effort with its provider's
1795
1838
  // default, not with the one the session is set to: pass that one on, so
1796
1839
  // the session keeps its own. This session's effort is never mirrored here.
1797
- const requested = normalizeEffort(args.effort);
1840
+ // A Copilot model named with its effort ("gpt-5.4-high") brings that effort.
1841
+ const requested = normalizeEffort(args.effort) || normalizeEffort(settled?.effort);
1798
1842
  const own = requested ? '' : normalizeEffort(conversation?.preferredReasoningEffort);
1799
1843
  const origin = await buildOrigin(ctx);
1800
1844
  const { sent, choice } = await postPromptWithEffort(ctx, { session, text: args.text, origin, model, mode }, {
@@ -1802,11 +1846,91 @@ export function createRemoteRelayDispatcher({
1802
1846
  source: requested ? EFFORT_SOURCES.requested : (own ? EFFORT_SOURCES.remote : ''),
1803
1847
  note: null,
1804
1848
  });
1805
- return deliverAndWait('send', ctx, { session, sent, waitSeconds: args.wait_seconds, extra: effortResult(choice) });
1849
+ return deliverAndWait('send', ctx, {
1850
+ session,
1851
+ sent,
1852
+ waitSeconds: args.wait_seconds,
1853
+ extra: { ...(modelNote ? { modelNote } : {}), ...effortResult(choice) },
1854
+ });
1855
+ }
1856
+
1857
+ /**
1858
+ * A model the agent named, checked against the list New Chat offers for
1859
+ * `provider` on the target (offeredModelList). Throws a DispatchFailure
1860
+ * (unknownModel) for one it does not offer, before anything is created or
1861
+ * sent. Returns `{ model, note }`: the id to use (the provider's casing, a
1862
+ * Claude alias resolved) and what the agent should know about it, the
1863
+ * model left unchecked for want of a list included.
1864
+ */
1865
+ function settleRequestedModel(ctx, catalog, provider, requested, { creating, extraModels = [] }) {
1866
+ const entry = catalog ? listProviders(catalog).find((candidate) => candidate.provider === provider) : null;
1867
+ const offered = catalog ? offeredModelList(catalog, provider) : null;
1868
+ const check = checkRequestedProviderModel({
1869
+ requestedModel: requested,
1870
+ provider,
1871
+ // What the message route accepts besides (send): the provider's whole
1872
+ // discovered list and the session's own model. Never a list by itself.
1873
+ models: offered ? [...offered, ...extraModels.map(toText).filter(Boolean)] : null,
1874
+ defaultModel: entry?.defaultModel || '',
1875
+ effortsFor: (base) => catalogEffortsFor(catalog?.models, provider, base),
1876
+ });
1877
+ const where = `on relay "${ctx.relay.name}"`;
1878
+ const label = providerModelLabel(provider);
1879
+ if (check.status === 'unknown') {
1880
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.unknownModel,
1881
+ `${unknownProviderModelText({ model: requested, provider, suggestions: check.suggestions, models: check.models, where })} ${creating ? 'No session was created.' : 'Nothing was sent.'}`,
1882
+ {
1883
+ provider,
1884
+ model: requested,
1885
+ suggestions: check.suggestions,
1886
+ models: check.models.slice(0, UNKNOWN_MODEL_LIST_MAX),
1887
+ note: 'Pass one of these ids (relay_info lists them per provider), or leave model out.',
1888
+ });
1889
+ }
1890
+ if (check.status === 'unchecked') {
1891
+ return {
1892
+ model: requested,
1893
+ note: `Model '${requested}' could not be checked: ${label} ${where} has no model list yet (its model discovery has not run or failed), so ${creating ? 'the session was created' : 'the prompt was sent'} with it as asked.`,
1894
+ };
1895
+ }
1896
+ const tierNote = check.tierDropped ? ` ${label} ${where} lists no "[1m]" id for it, so it runs with the standard context.` : '';
1897
+ if (check.status === 'alias') {
1898
+ return { model: check.model, note: `'${requested}' was taken as ${check.model}, the newest ${label} model of that name ${where}.${tierNote}` };
1899
+ }
1900
+ // A Copilot model with its effort: the bootstrap and the message route
1901
+ // know the listed id, so the effort goes on apart.
1902
+ if (check.base) return { model: check.base, effort: check.effort, note: `'${requested}' was taken as ${check.base} with effort ${check.effort}.` };
1903
+ if (check.tierDropped) return { model: check.model, note: `'${requested}' was taken as ${check.model}.${tierNote}` };
1904
+ // The same id in the provider's casing needs no word; another one does.
1905
+ if (check.model.toLowerCase() !== requested.toLowerCase()) {
1906
+ return { model: check.model, note: `'${requested}' is listed as ${check.model} ${where}; that is the model used.` };
1907
+ }
1908
+ return { model: check.model, note: null };
1909
+ }
1910
+
1911
+ /** settleRequestedModel for a send to `conversation` (its detail payload); an unread catalogue leaves it unchecked. */
1912
+ async function settleSendModel(ctx, conversation, requested) {
1913
+ const runtime = isPlainObject(conversation?.runtimeSession) ? conversation.runtimeSession : {};
1914
+ const provider = toText(runtime.providerType).toLowerCase() || 'github';
1915
+ let catalog = null;
1916
+ try {
1917
+ catalog = await loadCatalog(ctx);
1918
+ } catch {
1919
+ // No catalogue: the model goes unchecked, and the note says so.
1920
+ }
1921
+ const settings = catalog?.settings?.[provider];
1922
+ return settleRequestedModel(ctx, catalog, provider, requested, {
1923
+ creating: false,
1924
+ extraModels: [
1925
+ ...(Array.isArray(settings?.availableModels) ? settings.availableModels : []),
1926
+ runtime.providerModel,
1927
+ runtime.model,
1928
+ ],
1929
+ });
1806
1930
  }
1807
1931
 
1808
1932
  async function createSession(ctx, args) {
1809
- const catalog = await loadCatalog(ctx);
1933
+ const catalog = ctx.catalog || await loadCatalog(ctx);
1810
1934
  const providers = buildProviders(catalog);
1811
1935
  const configured = providers.filter((entry) => entry.configured).map((entry) => entry.provider);
1812
1936
  const provider = createProvider(ctx.caller, args);
@@ -1821,6 +1945,16 @@ export function createRemoteRelayDispatcher({
1821
1945
  let model = args.model || '';
1822
1946
  let modelSource = model ? 'requested' : '';
1823
1947
  let note = null;
1948
+ let variantEffort = '';
1949
+ if (model) {
1950
+ // Refused here when the provider does not offer it: a session created
1951
+ // with an id its provider never runs stays empty, and every send to it
1952
+ // fails.
1953
+ const settled = settleRequestedModel(ctx, catalog, provider, model, { creating: true });
1954
+ model = settled.model;
1955
+ note = settled.note;
1956
+ variantEffort = normalizeEffort(settled.effort);
1957
+ }
1824
1958
  // A cloud session without a requested model gets the Claude Cloud tab's
1825
1959
  // default, as New Chat does: the bootstrap picks it, nothing is mirrored.
1826
1960
  if (!model && cloud) {
@@ -1842,7 +1976,7 @@ export function createRemoteRelayDispatcher({
1842
1976
 
1843
1977
  // The effort mirrors the caller like the model does: this session's when
1844
1978
  // the remote lists it for the model (or lists nothing), else the remote's.
1845
- let choice = { effort: normalizeEffort(args.effort), source: EFFORT_SOURCES.requested, note: null };
1979
+ let choice = { effort: normalizeEffort(args.effort) || variantEffort, source: EFFORT_SOURCES.requested, note: null };
1846
1980
  if (!choice.effort && cloud) {
1847
1981
  // A cloud session takes no effort: none is mirrored, none is reported.
1848
1982
  choice = { effort: '', source: '', note: null };
@@ -1898,6 +2032,11 @@ export function createRemoteRelayDispatcher({
1898
2032
  `Relay "${ctx.relay.name}" did not return the new session's id.`);
1899
2033
  }
1900
2034
  const boundModel = toText(bootstrap?.selectedModel) || model;
2035
+ // A relay that swapped the asked model for another (one that predates
2036
+ // these checks does for Copilot) is named, never passed over in silence.
2037
+ if (modelSource === 'requested' && model && !isAutoModelId(model) && boundModel.toLowerCase() !== model.toLowerCase()) {
2038
+ note = [note, `Relay "${ctx.relay.name}" started the session on ${boundModel}, not on ${model} as asked.`].filter(Boolean).join(' ');
2039
+ }
1901
2040
  let sent;
1902
2041
  try {
1903
2042
  const posted = await postPromptWithEffort(ctx, { session, text: args.text, origin, model: boundModel, mode }, choice);
@@ -2679,10 +2818,39 @@ export function createRemoteRelayDispatcher({
2679
2818
  { relay: relay.name, retryAfterSeconds }));
2680
2819
  }
2681
2820
 
2821
+ // The target's catalogue, loaded before anyone is asked for approval when
2822
+ // the agent names a model: a card for a model that does not exist there
2823
+ // would be a question about nothing. createSession reuses it.
2824
+ let catalog = null;
2825
+ let settledModel = null;
2682
2826
  if (action === 'create_session') {
2683
2827
  const problem = createSessionProblem(caller, args);
2684
2828
  if (problem) return finish(failure(400, REMOTE_RELAY_ERROR_CODES.invalidInput, problem, { relay: relay.name }));
2685
2829
  }
2830
+ if ((action === 'create_session' || action === 'send') && args.model) {
2831
+ const early = { relay, caller, hops, conversationId, signal };
2832
+ try {
2833
+ if (action === 'create_session') {
2834
+ catalog = await loadCatalog(early);
2835
+ const provider = createProvider(caller, args);
2836
+ // A provider not set up there answers as such (createSession), not
2837
+ // with a model it has no list for.
2838
+ if (listProviders(catalog).find((entry) => entry.provider === provider)?.configured) {
2839
+ settleRequestedModel(early, catalog, provider, args.model, { creating: true });
2840
+ }
2841
+ } else {
2842
+ const conversation = await call(early, 'GET', sessionPath(args.session), { query: { limit: 1 } });
2843
+ settledModel = await settleSendModel(early, conversation, args.model);
2844
+ }
2845
+ } catch (error) {
2846
+ if (error instanceof DispatchFailure) {
2847
+ return finish(failure(error.status, error.code, error.message, { relay: relay.name, ...error.extra }));
2848
+ }
2849
+ // The target did not answer: the call itself reports that.
2850
+ catalog = null;
2851
+ settledModel = null;
2852
+ }
2853
+ }
2686
2854
 
2687
2855
  const localCreate = local && action === 'create_session';
2688
2856
  let holdsLocalCreate = false;
@@ -2756,7 +2924,7 @@ export function createRemoteRelayDispatcher({
2756
2924
  }
2757
2925
  }
2758
2926
 
2759
- const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate };
2927
+ const ctx = { relay, caller, hops, conversationId, signal, onPromptQueued: releaseLocalCreate, ...(catalog ? { catalog } : {}), ...(settledModel ? { settledModel } : {}) };
2760
2928
  const body = await ACTIONS[action](ctx, args);
2761
2929
  if (prompting) agentPrompts(conversationId).count += 1;
2762
2930
  const { summary, ...rest } = body;
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * "After a relay restart, tell interrupted sessions to carry on" (opt-in,
5
+ * per relay, off by default).
6
+ *
7
+ * Session workers outlive the relay's runtime, and a turn whose worker lived
8
+ * through the restart simply goes on. A turn whose worker did not (it died
9
+ * with the relay, or came back without the turn) is dealt with by the
10
+ * recovery paths, and most of them requeue it: the row runs again by itself,
11
+ * and nothing more is needed. A few cannot run again, because the prompt
12
+ * already reached the agent and a second delivery would run it twice:
13
+ *
14
+ * - a background continuation (a turn the agent opened itself), torn down;
15
+ * - a steer the CLI had already taken (`consumed_at`), failed;
16
+ * - a turn that had started (activity, a stream) when its live worker no
17
+ * longer claimed it, failed instead of replayed (`turn-replay-blocked`).
18
+ *
19
+ * The agent never hears that its turn stopped. With the setting on, such a
20
+ * session gets ONE note of the relay's in the conversation once its rows are
21
+ * settled: the relay restarted, the turn was interrupted, check what you
22
+ * were doing and carry on. The user's message is never sent again.
23
+ *
24
+ * Which turns count: the rows that were `processing` when this runtime
25
+ * started (`captureBoot`), followed through the recovery paths
26
+ * (`noteOutcome`). A row its worker claims after the start (a heartbeat
27
+ * that lists it: the worker lived through the restart and has the turn) is
28
+ * the restart's no more: whatever ends it later is not put down to it.
29
+ * A conversation gets a note when one of its rows was cut off and none was
30
+ * requeued (that one runs again: a note would double the work), claimed by
31
+ * a worker that lived through the restart, or failed for another reason.
32
+ * Nothing for idle sessions, nothing when
33
+ * the setting is off when the note would be sent, nothing for an archived or
34
+ * deleted conversation, nothing while a usage-limit follow-up is held (it
35
+ * carries the turn on itself), at most one note per conversation and
36
+ * runtime. The note is an ordinary pending row, queued behind whatever the
37
+ * user has queued already, so it never jumps ahead of a user message; its
38
+ * delivery starts the session's worker as for any message.
39
+ */
40
+
41
+ import { randomUUID } from 'node:crypto';
42
+
43
+ export const RESTART_RESUME_SETTING_KEY = 'restart_resume_notes';
44
+ export const RESTART_RESUME_KIND = 'relay-restart-resume';
45
+ export const RESTART_RESUME_LEAD = 'Automatic message from OAR, not from the user:';
46
+
47
+ // How long after the start a cut-off turn is still put down to the restart.
48
+ const DEFAULT_WINDOW_MS = 30 * 60_000;
49
+
50
+ /** The stored value as on or off; off unless it was switched on. */
51
+ export function readRestartResumeSetting(raw) {
52
+ return ['1', 'true', 'on'].includes(String(raw ?? '').trim().toLowerCase());
53
+ }
54
+
55
+ export function parseRestartResumeUpdate(value) {
56
+ if (typeof value !== 'boolean') return { ok: false, error: 'enabled must be true or false' };
57
+ return { ok: true, enabled: value };
58
+ }
59
+
60
+ function defaultFormatTime(ms) {
61
+ const at = new Date(Number(ms));
62
+ if (!Number.isFinite(at.getTime())) return 'just now';
63
+ return `${at.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
64
+ }
65
+
66
+ /** The note's text. */
67
+ export function buildRestartResumeNoteText({ restartedAt, formatTime = defaultFormatTime } = {}) {
68
+ return `${RESTART_RESUME_LEAD} the relay restarted at ${formatTime(restartedAt)}, and the turn you were working on was interrupted before it finished. `
69
+ + 'Check where you were (the files you changed, the commands and background tasks you started) and carry on from there. '
70
+ + 'Do not repeat steps that already completed; if the work was already done, say so briefly.';
71
+ }
72
+
73
+ const OUTCOMES = new Set(['cut', 'requeued', 'failed', 'claimed']);
74
+
75
+ function text(value) {
76
+ return String(value ?? '').trim();
77
+ }
78
+
79
+ export function createRestartResumeService({
80
+ db,
81
+ stmts,
82
+ isEnabled = () => false,
83
+ emit = () => {},
84
+ wake = () => {},
85
+ now = () => Date.now(),
86
+ bootAt = now(),
87
+ windowMs = DEFAULT_WINDOW_MS,
88
+ formatTime = defaultFormatTime,
89
+ logger = console,
90
+ } = {}) {
91
+ if (!db || !stmts) throw new Error('createRestartResumeService requires db and stmts');
92
+ const listProcessing = db.prepare(`
93
+ SELECT id, conversation_id, model, model_variant_id, reasoning_effort, context_tier, relay_mode,
94
+ owner_sdk_session_id, runtime_session_id
95
+ FROM queue WHERE status = 'processing'
96
+ `);
97
+ const statusOf = db.prepare(`SELECT status FROM queue WHERE id = ?`);
98
+ let heldPause = null;
99
+ try {
100
+ // A held usage-limit follow-up (usage-limit-pause-service.mjs).
101
+ heldPause = db.prepare(`
102
+ SELECT 1 FROM queue
103
+ WHERE conversation_id = ? AND status = 'pending' AND usage_limit_pause IS NOT NULL
104
+ LIMIT 1
105
+ `);
106
+ } catch {}
107
+
108
+ // queue id → conversation id, for the rows processing at the start.
109
+ const watched = new Map();
110
+ // conversation id → { rows: Map(queue id → { row, outcome }) }
111
+ const conversations = new Map();
112
+ // Conversations that had their note in this runtime.
113
+ const noted = new Set();
114
+
115
+ function log(line) {
116
+ try { logger?.log?.(`[restart-resume] ${line}`); } catch {}
117
+ }
118
+
119
+ function expired() {
120
+ return now() - bootAt > windowMs;
121
+ }
122
+
123
+ /** The rows processing when this runtime started; returns how many. */
124
+ function captureBoot() {
125
+ let rows = [];
126
+ try { rows = listProcessing.all(); } catch {}
127
+ for (const row of rows) {
128
+ const conversationId = text(row.conversation_id);
129
+ const id = text(row.id);
130
+ if (!conversationId || !id) continue;
131
+ watched.set(id, conversationId);
132
+ let entry = conversations.get(conversationId);
133
+ if (!entry) conversations.set(conversationId, entry = { rows: new Map() });
134
+ entry.rows.set(id, { row, outcome: null });
135
+ }
136
+ if (rows.length) log(`${rows.length} turn(s) were running when the relay started; following them`);
137
+ return rows.length;
138
+ }
139
+
140
+ /**
141
+ * What a recovery path did with a row: `cut` (it cannot run again),
142
+ * `requeued` (it runs again by itself) or `failed` (ended for another
143
+ * reason); or `claimed`: its worker, alive after the restart, holds it.
144
+ * Only the rows of the start count, and only their first outcome.
145
+ */
146
+ function noteOutcome(row, outcome) {
147
+ const id = text(row?.id ?? row);
148
+ const conversationId = watched.get(id);
149
+ if (!conversationId || !OUTCOMES.has(outcome)) return false;
150
+ watched.delete(id);
151
+ const entry = conversations.get(conversationId)?.rows.get(id);
152
+ if (entry && !entry.outcome) entry.outcome = outcome;
153
+ settle(conversationId);
154
+ return true;
155
+ }
156
+
157
+ // Decides a conversation once none of its rows of the start is processing.
158
+ function settle(conversationId) {
159
+ const entry = conversations.get(conversationId);
160
+ if (!entry) return;
161
+ for (const [id, item] of entry.rows) {
162
+ if (item.outcome) continue;
163
+ let status = '';
164
+ try { status = text(statusOf.get(id)?.status).toLowerCase(); } catch {}
165
+ if (status === 'processing') return;
166
+ // Back in the queue by a path that did not say so: it runs again.
167
+ item.outcome = status === 'pending' || status === 'parked' ? 'requeued' : 'settled';
168
+ watched.delete(id);
169
+ }
170
+ conversations.delete(conversationId);
171
+ const outcomes = [...entry.rows.values()].map((item) => item.outcome);
172
+ if (!outcomes.includes('cut')) return;
173
+ if (outcomes.includes('requeued')) {
174
+ log(`conv=${conversationId.slice(0, 8)} no note: the interrupted turn runs again by itself`);
175
+ return;
176
+ }
177
+ if (outcomes.includes('claimed')) {
178
+ log(`conv=${conversationId.slice(0, 8)} no note: its worker lived through the restart and has its turn`);
179
+ return;
180
+ }
181
+ if (outcomes.includes('failed')) {
182
+ log(`conv=${conversationId.slice(0, 8)} no note: the turn failed, the failure says what to do`);
183
+ return;
184
+ }
185
+ const cut = [...entry.rows.values()].find((item) => item.outcome === 'cut')?.row || null;
186
+ sendNote(conversationId, cut);
187
+ }
188
+
189
+ function skip(conversationId, why) {
190
+ log(`conv=${conversationId.slice(0, 8)} no note: ${why}`);
191
+ return null;
192
+ }
193
+
194
+ function sendNote(conversationId, cutRow) {
195
+ if (noted.has(conversationId)) return skip(conversationId, 'it had one after this restart');
196
+ let enabled = false;
197
+ try { enabled = isEnabled() === true; } catch {}
198
+ if (!enabled) return skip(conversationId, 'the setting is off');
199
+ const conversation = stmts.getConvAnyStatus?.get?.(conversationId) || null;
200
+ if (!conversation || text(conversation.status) === 'deleted') return skip(conversationId, 'the conversation is gone');
201
+ if (Number(conversation.archived) === 1) return skip(conversationId, 'the conversation is archived');
202
+ try {
203
+ if (heldPause?.get(conversationId)) return skip(conversationId, 'a usage-limit follow-up carries the turn on');
204
+ } catch {}
205
+ noted.add(conversationId);
206
+
207
+ const at = new Date(now()).toISOString();
208
+ const messageId = randomUUID();
209
+ const noteText = buildRestartResumeNoteText({ restartedAt: bootAt, formatTime });
210
+ const runtimeSession = stmts.getRuntimeSessionByConversation?.get?.(conversationId) || null;
211
+ const latestModel = stmts.getLatestConversationModel?.get?.(conversationId)?.model || null;
212
+ // The model and mode the interrupted turn ran with.
213
+ const model = text(cutRow?.model) || text(latestModel) || text(runtimeSession?.provider_model) || text(runtimeSession?.model) || null;
214
+ const modelVariant = text(cutRow?.model_variant_id) || model;
215
+ const relayMode = text(cutRow?.relay_mode) || 'agent';
216
+ const owner = text(cutRow?.owner_sdk_session_id) || text(runtimeSession?.sdk_session_id) || null;
217
+ const write = db.transaction(() => {
218
+ stmts.insertMsg.run(
219
+ messageId,
220
+ conversationId,
221
+ 'user',
222
+ noteText,
223
+ modelVariant || null,
224
+ relayMode,
225
+ null,
226
+ at,
227
+ modelVariant || null,
228
+ null,
229
+ text(modelVariant).toLowerCase() === 'auto' ? 'auto' : 'manual',
230
+ );
231
+ stmts.setMessageKind?.run?.(RESTART_RESUME_KIND, messageId);
232
+ stmts.updateConvTime?.run?.(at, conversationId);
233
+ const queueArgs = [
234
+ messageId,
235
+ conversationId,
236
+ text(cutRow?.runtime_session_id) || runtimeSession?.id || null,
237
+ 0,
238
+ model,
239
+ modelVariant || null,
240
+ text(cutRow?.reasoning_effort) || null,
241
+ text(cutRow?.context_tier) || null,
242
+ relayMode,
243
+ noteText,
244
+ null,
245
+ at,
246
+ owner,
247
+ owner ? at : null,
248
+ null,
249
+ null,
250
+ ];
251
+ if (stmts.queueHasImageOperationId || stmts.insertQ?.source?.includes?.('image_operation_id')) queueArgs.push(null);
252
+ stmts.insertQ.run(...queueArgs);
253
+ });
254
+ try {
255
+ write();
256
+ } catch (error) {
257
+ noted.delete(conversationId);
258
+ try { logger?.warn?.(`[restart-resume] conv=${conversationId.slice(0, 8)} note not queued: ${error?.message || error}`); } catch {}
259
+ return null;
260
+ }
261
+ try {
262
+ emit('user_message', {
263
+ conversationId,
264
+ messageId,
265
+ senderClientId: null,
266
+ message: {
267
+ role: 'user',
268
+ text: noteText,
269
+ model: modelVariant || null,
270
+ mode: relayMode,
271
+ attachments: [],
272
+ timestamp: at,
273
+ kind: RESTART_RESUME_KIND,
274
+ },
275
+ });
276
+ } catch {}
277
+ try { emit('message_status', { messageId, conversationId, status: 'pending' }); } catch {}
278
+ try { wake({ conversationId, ownerSessionId: owner, messageId }); } catch {}
279
+ log(`conv=${conversationId.slice(0, 8)} note queued: its turn was cut off by the restart`);
280
+ return messageId;
281
+ }
282
+
283
+ /** Decides the conversations whose rows settled on their own; ends the watch after the window. */
284
+ function tick() {
285
+ if (!conversations.size) return;
286
+ if (expired()) {
287
+ watched.clear();
288
+ conversations.clear();
289
+ return;
290
+ }
291
+ for (const conversationId of [...conversations.keys()]) settle(conversationId);
292
+ }
293
+
294
+ return {
295
+ captureBoot,
296
+ noteOutcome,
297
+ tick,
298
+ /** What is still followed, for tests and diagnostics. */
299
+ pending() {
300
+ return [...conversations.keys()];
301
+ },
302
+ };
303
+ }
@@ -296,6 +296,8 @@ export function createSessionWorkerProcessInspector({
296
296
  platform = process.platform,
297
297
  execFileSyncImpl = execFileSync,
298
298
  execFileImpl = execFile,
299
+ // Where Windows keeps PowerShell (the bare name when it is not there).
300
+ systemToolPath = windowsSystemToolPath,
299
301
  now = () => Date.now(),
300
302
  snapshotCacheMs = SNAPSHOT_CACHE_MS,
301
303
  slowReadMs = SLOW_READ_MS,
@@ -320,7 +322,7 @@ export function createSessionWorkerProcessInspector({
320
322
 
321
323
  function getWindowsProcessSnapshot() {
322
324
  if (platform !== 'win32') return [];
323
- const output = execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
325
+ const output = execFileSyncImpl(systemToolPath('powershell.exe'), ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT], {
324
326
  // stderr is kept (not shown): a failed read reports it.
325
327
  stdio: ['ignore', 'pipe', 'pipe'],
326
328
  windowsHide: true,
@@ -376,7 +378,7 @@ export function createSessionWorkerProcessInspector({
376
378
  if (platform !== 'win32') return Promise.resolve([]);
377
379
  return new Promise((resolve, reject) => {
378
380
  execFileImpl(
379
- windowsSystemToolPath('powershell.exe'),
381
+ systemToolPath('powershell.exe'),
380
382
  ['-NoProfile', '-Command', WINDOWS_PROCESS_SNAPSHOT_SCRIPT],
381
383
  { windowsHide: true, timeout: WINDOWS_PROCESS_SNAPSHOT_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
382
384
  (error, stdout, stderr) => {
@@ -545,7 +547,7 @@ export function createSessionWorkerProcessInspector({
545
547
  function stopWindowsPids(pids) {
546
548
  const ids = uniquePids(pids);
547
549
  if (!ids.length) return [];
548
- execFileSyncImpl(windowsSystemToolPath('powershell.exe'), ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
550
+ execFileSyncImpl(systemToolPath('powershell.exe'), ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)], {
549
551
  stdio: ['ignore', 'ignore', 'ignore'],
550
552
  });
551
553
  return ids;
@@ -557,7 +559,7 @@ export function createSessionWorkerProcessInspector({
557
559
  if (!ids.length) return Promise.resolve([]);
558
560
  return new Promise((resolve, reject) => {
559
561
  execFileImpl(
560
- windowsSystemToolPath('powershell.exe'),
562
+ systemToolPath('powershell.exe'),
561
563
  ['-NoProfile', '-Command', buildStopWindowsPidsScript(ids)],
562
564
  { windowsHide: true, timeout: WINDOWS_STOP_TIMEOUT_MS, maxBuffer: WINDOWS_PROCESS_SNAPSHOT_MAX_BUFFER },
563
565
  (error) => {