@oar-sh/oar 0.9.12 → 0.9.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +206 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/README.md +186 -11
- package/server/claude-worker/claude-session-process.mjs +53 -2
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +57 -3
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +67 -0
- package/server/relay-tools.md +5 -0
- package/server/repositories/message-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +155 -27
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +75 -3
- package/server/server-runtime.mjs +192 -137
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +9 -2
- package/server/services/relay-event-service.mjs +27 -9
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +380 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +187 -19
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-service.mjs +217 -18
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +15 -8
- package/shared/task-output-text.mjs +196 -83
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -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
|
-
|
|
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
|
-
?
|
|
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) ||
|
|
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 =
|
|
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
|
-
|
|
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, {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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) => {
|