@oar-sh/oar 0.9.12 → 0.9.14
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/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- 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/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- 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 +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- 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-provider-indicator.mjs +18 -8
- 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/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- 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 +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- 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/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- 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 +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- 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 +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
* boot. A subscription ends on unsubscribe, when its watcher is archived or
|
|
33
33
|
* deleted, when its last listed session is, or after firing (`once`).
|
|
34
34
|
*
|
|
35
|
+
* relay_restarted is the relay's own event, opt-in by name: each start of
|
|
36
|
+
* the runtime (noteRelayStarted) wakes the subscriptions of this relay's
|
|
37
|
+
* watchers that name it, once, with how the run before ended
|
|
38
|
+
* (noteRelayExit). A conversation whose turn answered relay_restarted when
|
|
39
|
+
* the restart was requested (noteRestartRequested) is not woken by it: a
|
|
40
|
+
* woken turn that restarts again ends the chain.
|
|
41
|
+
*
|
|
35
42
|
* Across paired relays (migrations/0012-cross-relay-event-subscriptions.mjs)
|
|
36
43
|
* this service plays both sides:
|
|
37
44
|
*
|
|
@@ -57,13 +64,21 @@ import {
|
|
|
57
64
|
RELAY_EVENT_LINK_LOST_TYPE,
|
|
58
65
|
RELAY_EVENT_LINK_RESTORED_TYPE,
|
|
59
66
|
RELAY_EVENT_MESSAGE_KIND,
|
|
67
|
+
RELAY_EVENT_RELAY_LEVEL_TYPES,
|
|
68
|
+
RELAY_EVENT_RELAY_RESTARTED_TYPE,
|
|
69
|
+
RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
|
|
70
|
+
RELAY_EVENT_SUBSCRIBABLE_TYPES,
|
|
60
71
|
RELAY_EVENT_TYPES,
|
|
61
72
|
RELAY_EVENT_VERSION,
|
|
62
73
|
buildRelayEventMessageText,
|
|
63
74
|
compileRelayEventMatch,
|
|
75
|
+
effectiveRelayEventTypes,
|
|
76
|
+
isLegacyDefaultRelayEventTypes,
|
|
64
77
|
isRelayEventType,
|
|
78
|
+
isRelayLevelOnly,
|
|
65
79
|
isUrgentRelayEventType,
|
|
66
80
|
relayEventMatchText,
|
|
81
|
+
relayEventTypesTake,
|
|
67
82
|
} from '../../shared/relay-events.mjs';
|
|
68
83
|
|
|
69
84
|
export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
@@ -72,6 +87,12 @@ export const RELAY_EVENT_SELF_RELAY_ID = 'self';
|
|
|
72
87
|
// conversation id, so nothing a paired relay asks for is ever written into
|
|
73
88
|
// one of this relay's conversations.
|
|
74
89
|
export const RELAY_EVENT_SERVED_WATCHER_PREFIX = 'remote:';
|
|
90
|
+
// app_settings: how the runtime before this one ended, written on its way
|
|
91
|
+
// out (noteRelayExit) and taken by the next start (noteRelayStarted).
|
|
92
|
+
export const RELAY_LAST_EXIT_SETTING_KEY = 'relay_last_exit';
|
|
93
|
+
// Older than this, a record is not of the run just before this one. Long:
|
|
94
|
+
// a relay stopped for a weekend still says how; the line shows the stop time.
|
|
95
|
+
export const RELAY_LAST_EXIT_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
|
|
75
96
|
|
|
76
97
|
const HOUR_MS = 60 * 60_000;
|
|
77
98
|
const DAY_MS = 24 * HOUR_MS;
|
|
@@ -286,6 +307,19 @@ export function createRelayEventService({
|
|
|
286
307
|
AND COALESCE(m.kind, '') <> ?
|
|
287
308
|
`),
|
|
288
309
|
deleteDeliveredBefore: db.prepare(RELAY_EVENT_PRUNE_SQL.deleteDeliveredBefore),
|
|
310
|
+
// The loop guard: conversations whose running turn answers relay_restarted.
|
|
311
|
+
listRestartWokenRunning: db.prepare(`
|
|
312
|
+
SELECT DISTINCT q.conversation_id FROM queue q JOIN relay_events e ON e.message_id = q.id
|
|
313
|
+
WHERE q.status = 'processing' AND e.type = ?
|
|
314
|
+
`),
|
|
315
|
+
} : null;
|
|
316
|
+
const exitSql = available && tableExists('app_settings') ? {
|
|
317
|
+
get: db.prepare(`SELECT value FROM app_settings WHERE key = ?`),
|
|
318
|
+
put: db.prepare(`
|
|
319
|
+
INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)
|
|
320
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
321
|
+
`),
|
|
322
|
+
take: db.prepare(`DELETE FROM app_settings WHERE key = ?`),
|
|
289
323
|
} : null;
|
|
290
324
|
// Without migration 0012 the table has no remote_sync column.
|
|
291
325
|
const deleteEndedSubscriptionsBefore = available
|
|
@@ -376,6 +410,12 @@ export function createRelayEventService({
|
|
|
376
410
|
let sweepTimer = null;
|
|
377
411
|
let started = false;
|
|
378
412
|
let lastPruneAt = 0;
|
|
413
|
+
// relay_restarted: the conversations a requested restart does not wake
|
|
414
|
+
// (the loop guard), whether this runtime's end is written, and whether its
|
|
415
|
+
// start was noted.
|
|
416
|
+
const restartNoWake = new Set();
|
|
417
|
+
let exitNoted = false;
|
|
418
|
+
let startNoted = false;
|
|
379
419
|
|
|
380
420
|
function isoNow() {
|
|
381
421
|
return new Date(now()).toISOString();
|
|
@@ -403,6 +443,11 @@ export function createRelayEventService({
|
|
|
403
443
|
return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
|
|
404
444
|
}
|
|
405
445
|
|
|
446
|
+
/** A subscription's events; the old default list counts as today's default. */
|
|
447
|
+
function storedTypes(row) {
|
|
448
|
+
return effectiveRelayEventTypes(parseJson(row?.events_json, []));
|
|
449
|
+
}
|
|
450
|
+
|
|
406
451
|
function shapeSubscription(row) {
|
|
407
452
|
if (!row) return null;
|
|
408
453
|
const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
|
|
@@ -431,7 +476,10 @@ export function createRelayEventService({
|
|
|
431
476
|
sessions: scope === 'all'
|
|
432
477
|
? 'all'
|
|
433
478
|
: sessionIds.map((id) => ({ id, title: mirror ? (remoteTitles.get(id) || null) : (toText(conversation(id)?.title) || null) })),
|
|
434
|
-
|
|
479
|
+
// A mirror shows what the paired relay applies; a subscription here,
|
|
480
|
+
// stored with the old default, the default that applies now.
|
|
481
|
+
events: (mirror ? parseJson(row.events_json, []) : storedTypes(row))
|
|
482
|
+
.filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)),
|
|
435
483
|
once: Number(row.once || 0) === 1,
|
|
436
484
|
...(toText(row.match) ? { match: row.match } : {}),
|
|
437
485
|
stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
|
|
@@ -487,12 +535,25 @@ export function createRelayEventService({
|
|
|
487
535
|
/**
|
|
488
536
|
* What a subscribe asks for, checked: `{ all, ids, types, matchText,
|
|
489
537
|
* stalled, heartbeat }`. Throws RelayEventError (`invalid`, `notFound`).
|
|
490
|
-
* `exclude` is the watcher, never watched by itself.
|
|
538
|
+
* `exclude` is the watcher, never watched by itself. `relayLevel` allows
|
|
539
|
+
* relay_restarted (this relay's own watchers only); a subscription that
|
|
540
|
+
* names nothing else needs no sessions.
|
|
491
541
|
*/
|
|
492
|
-
function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '' }) {
|
|
542
|
+
function prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude = '', relayLevel = false }) {
|
|
493
543
|
const all = scope === 'all';
|
|
494
544
|
const ids = all ? [] : [...new Set((Array.isArray(sessionIds) ? sessionIds : []).map(toText).filter(Boolean))];
|
|
495
|
-
|
|
545
|
+
const named = Array.isArray(events) ? events.map(toText).filter(Boolean) : [];
|
|
546
|
+
const relayLevelNamed = named.filter((type) => RELAY_EVENT_RELAY_LEVEL_TYPES.includes(type));
|
|
547
|
+
if (relayLevelNamed.length && !relayLevel) {
|
|
548
|
+
throw new RelayEventError('invalid', `${relayLevelNamed.join(', ')} is about the relay the watcher runs on and cannot be watched on another relay (an outage there shows as link_lost and link_restored).`);
|
|
549
|
+
}
|
|
550
|
+
if (!all && !ids.length && !isRelayLevelOnly(named)) {
|
|
551
|
+
throw new RelayEventError('invalid', `subscribe needs sessions (ids from list_sessions) or "all"${relayLevel ? ', unless events names only relay_restarted' : ''}.`);
|
|
552
|
+
}
|
|
553
|
+
// No session, no work that could run: a heartbeat would never come.
|
|
554
|
+
if (!all && !ids.length && heartbeatMinutes !== null && heartbeatMinutes !== undefined && heartbeatMinutes !== '') {
|
|
555
|
+
throw new RelayEventError('invalid', 'heartbeat_minutes needs sessions to watch; relay_restarted alone has none.');
|
|
556
|
+
}
|
|
496
557
|
if (ids.length > RELAY_EVENT_LIMITS.sessionsMax) {
|
|
497
558
|
throw new RelayEventError('invalid', `subscribe takes at most ${RELAY_EVENT_LIMITS.sessionsMax} sessions; use "all" for more.`);
|
|
498
559
|
}
|
|
@@ -504,9 +565,13 @@ export function createRelayEventService({
|
|
|
504
565
|
throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
|
|
505
566
|
}
|
|
506
567
|
const types = Array.isArray(events) && events.length
|
|
507
|
-
? [...new Set(events.map(toText).filter((type) =>
|
|
568
|
+
? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)))]
|
|
508
569
|
: [...RELAY_EVENT_DEFAULT_TYPES];
|
|
509
|
-
if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${
|
|
570
|
+
if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_SUBSCRIBABLE_TYPES.join(', ')}`);
|
|
571
|
+
// Named exactly as the old default was stored, this list would be read
|
|
572
|
+
// as today's default and lose its task_finished (effectiveRelayEventTypes);
|
|
573
|
+
// subagent_finished beside it changes nothing it takes and keeps it named.
|
|
574
|
+
if (isLegacyDefaultRelayEventTypes(types)) types.push(RELAY_EVENT_SUBAGENT_FINISHED_TYPE);
|
|
510
575
|
const compiled = compileRelayEventMatch(match);
|
|
511
576
|
if (!compiled.ok) throw new RelayEventError('invalid', compiled.error);
|
|
512
577
|
const stalled = Number.isFinite(Number(stalledMinutes)) && stalledMinutes !== null && stalledMinutes !== ''
|
|
@@ -538,7 +603,7 @@ export function createRelayEventService({
|
|
|
538
603
|
if (!available) throw new RelayEventError('unavailable', 'Event subscriptions are not available on this relay right now (its storage is missing; see the server log).');
|
|
539
604
|
const watcher = toText(watcherConversationId);
|
|
540
605
|
if (!isLive(conversation(watcher))) throw new RelayEventError('notFound', 'The watching conversation was not found or is archived.');
|
|
541
|
-
const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher });
|
|
606
|
+
const prepared = prepareSubscription({ scope, sessionIds, events, match, stalledMinutes, heartbeatMinutes, exclude: watcher, relayLevel: true });
|
|
542
607
|
const { all, ids, types } = prepared;
|
|
543
608
|
checkWatcherCapacity(watcher);
|
|
544
609
|
const id = `sub_${randomBytes(5).toString('hex')}`;
|
|
@@ -560,7 +625,8 @@ export function createRelayEventService({
|
|
|
560
625
|
// The watched set starts from what it is doing now: all_idle fires when
|
|
561
626
|
// work that is running now (or starts later) has ended.
|
|
562
627
|
noteBusy(sql.getSubscription.get(id));
|
|
563
|
-
|
|
628
|
+
const what = all ? 'all sessions' : (ids.length ? `${ids.length} session${ids.length === 1 ? '' : 's'}` : 'this relay');
|
|
629
|
+
log(`conv=${shortId(watcher)} subscribed ${id} to ${what} (${types.join(',')})${once ? ' once' : ''}`);
|
|
564
630
|
broadcast();
|
|
565
631
|
return subscription;
|
|
566
632
|
}
|
|
@@ -771,8 +837,9 @@ export function createRelayEventService({
|
|
|
771
837
|
return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
|
|
772
838
|
}
|
|
773
839
|
|
|
774
|
-
|
|
775
|
-
|
|
840
|
+
/** `event` is `{ type, detail }`: a task_finished's kind decides under subagent_finished. */
|
|
841
|
+
function wants(row, event) {
|
|
842
|
+
return relayEventTypesTake(storedTypes(row), event);
|
|
776
843
|
}
|
|
777
844
|
|
|
778
845
|
/** `match` filters the events it can be tried on; the others pass. */
|
|
@@ -807,7 +874,7 @@ export function createRelayEventService({
|
|
|
807
874
|
if (!available || !RELAY_EVENT_TYPES.includes(type)) return 0;
|
|
808
875
|
const id = toText(sessionId);
|
|
809
876
|
if (!id) return 0;
|
|
810
|
-
const rows = localWatchRows().filter((row) => wants(row, type) && subscriptionCovers(row, id));
|
|
877
|
+
const rows = localWatchRows().filter((row) => wants(row, { type, detail }) && subscriptionCovers(row, id));
|
|
811
878
|
if (!rows.length) return 0;
|
|
812
879
|
const event = buildEvent(type, id, detail);
|
|
813
880
|
return enqueueForRows(rows, event, id);
|
|
@@ -1271,7 +1338,7 @@ export function createRelayEventService({
|
|
|
1271
1338
|
const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
|
|
1272
1339
|
.map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
|
|
1273
1340
|
.filter((session) => session.id);
|
|
1274
|
-
const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) =>
|
|
1341
|
+
const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type));
|
|
1275
1342
|
const id = `sub_${randomBytes(5).toString('hex')}`;
|
|
1276
1343
|
const at = isoNow();
|
|
1277
1344
|
remoteSql.insertMirror.run(
|
|
@@ -1599,6 +1666,131 @@ export function createRelayEventService({
|
|
|
1599
1666
|
return ended;
|
|
1600
1667
|
}
|
|
1601
1668
|
|
|
1669
|
+
// ─── The relay's own start: relay_restarted ────────────────────────────────
|
|
1670
|
+
|
|
1671
|
+
/** Conversations whose running turn answers a relay_restarted event. */
|
|
1672
|
+
function restartWokenConversations() {
|
|
1673
|
+
if (!available) return [];
|
|
1674
|
+
try {
|
|
1675
|
+
return sql.listRestartWokenRunning.all(RELAY_EVENT_RELAY_RESTARTED_TYPE).map((row) => toText(row.conversation_id)).filter(Boolean);
|
|
1676
|
+
} catch {
|
|
1677
|
+
return [];
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* A restart or shutdown was requested: a conversation whose turn answers
|
|
1683
|
+
* relay_restarted right now is not woken by it (the request may be that
|
|
1684
|
+
* turn's). Called on every request while one is queued; returns them.
|
|
1685
|
+
*/
|
|
1686
|
+
function noteRestartRequested() {
|
|
1687
|
+
const woken = restartWokenConversations();
|
|
1688
|
+
for (const id of woken) restartNoWake.add(id);
|
|
1689
|
+
if (woken.length) log(`restart requested while ${woken.map(shortId).join(', ')} answer${woken.length === 1 ? 's' : ''} relay_restarted: not woken by it`);
|
|
1690
|
+
return [...restartNoWake];
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** The queued request was withdrawn (or never queued): nothing is held back. */
|
|
1694
|
+
function noteRestartCancelled() {
|
|
1695
|
+
restartNoWake.clear();
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* This runtime ends cleanly: how, for the next start to tell. The first
|
|
1700
|
+
* call wins (a queued request's details before the generic exit's); none
|
|
1701
|
+
* before noteRelayStarted. `requestedAt` set means the end was a queued (or immediate) request.
|
|
1702
|
+
*/
|
|
1703
|
+
function noteRelayExit({ action = 'shutdown', reason = '', requestedBy = null, requestedAt = null } = {}) {
|
|
1704
|
+
// A runtime stopped before it listened (and took the record) leaves the
|
|
1705
|
+
// record of the run before it for the next start.
|
|
1706
|
+
if (!exitSql || exitNoted || !startNoted) return false;
|
|
1707
|
+
exitNoted = true;
|
|
1708
|
+
const requested = !!toText(requestedAt);
|
|
1709
|
+
// The runtime's own reasons may carry a process id (`owner-pid-missing:<pid>`):
|
|
1710
|
+
// not for an agent's eyes.
|
|
1711
|
+
const ownReason = requested ? toText(reason) : toText(reason).replace(/:\d+\b/g, '');
|
|
1712
|
+
const record = {
|
|
1713
|
+
v: 1,
|
|
1714
|
+
requested,
|
|
1715
|
+
action: action === 'restart' ? 'restart' : 'shutdown',
|
|
1716
|
+
reason: ownReason.slice(0, 140) || null,
|
|
1717
|
+
requestedBy: toText(requestedBy).slice(0, 80) || null,
|
|
1718
|
+
requestedAt: toText(requestedAt) || null,
|
|
1719
|
+
stoppedAt: isoNow(),
|
|
1720
|
+
noWake: [...restartNoWake],
|
|
1721
|
+
};
|
|
1722
|
+
try {
|
|
1723
|
+
exitSql.put.run(RELAY_LAST_EXIT_SETTING_KEY, JSON.stringify(record), record.stoppedAt);
|
|
1724
|
+
return true;
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
warn(`how this run ended was not stored: ${toText(error?.message || error).slice(0, 200)}`);
|
|
1727
|
+
return false;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
function restartedDetail(exit, startedAt) {
|
|
1732
|
+
if (!exit) return { stop: 'unknown', startedAt };
|
|
1733
|
+
const base = { startedAt, stoppedAt: toText(exit.stoppedAt) || null, reason: toText(exit.reason) || null };
|
|
1734
|
+
if (exit.requested !== true) return { stop: 'stopped', ...base };
|
|
1735
|
+
return {
|
|
1736
|
+
stop: 'requested',
|
|
1737
|
+
...base,
|
|
1738
|
+
action: exit.action === 'shutdown' ? 'shutdown' : 'restart',
|
|
1739
|
+
requestedBy: toText(exit.requestedBy) || null,
|
|
1740
|
+
requestedAt: toText(exit.requestedAt) || null,
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* The runtime started (it listens). Takes how the run before ended (read
|
|
1746
|
+
* and deleted with the events written, so a start is told once) and gives
|
|
1747
|
+
* every active subscription of this relay's live watchers that names
|
|
1748
|
+
* relay_restarted one event; a `once` one ends with it. Watchers the
|
|
1749
|
+
* restart's request held back (noteRestartRequested) are left out, and
|
|
1750
|
+
* their `once` subscriptions end too (`held-back`): that restart was the
|
|
1751
|
+
* one they were for, and a later one would wake them with a stale "carry
|
|
1752
|
+
* on". A record older than RELAY_LAST_EXIT_MAX_AGE_MS is not the run just
|
|
1753
|
+
* before (an older OAR, which neither writes nor takes it, may have run in
|
|
1754
|
+
* between): it is dropped unread. Once per runtime. Returns `{ recorded,
|
|
1755
|
+
* skipped }`.
|
|
1756
|
+
*/
|
|
1757
|
+
function noteRelayStarted() {
|
|
1758
|
+
if (!available || startNoted) return { recorded: 0, skipped: [] };
|
|
1759
|
+
startNoted = true;
|
|
1760
|
+
const startedAt = isoNow();
|
|
1761
|
+
let recorded = 0;
|
|
1762
|
+
let skipped = [];
|
|
1763
|
+
const heldBack = [];
|
|
1764
|
+
db.transaction(() => {
|
|
1765
|
+
let exit = null;
|
|
1766
|
+
if (exitSql) {
|
|
1767
|
+
exit = parseJson(exitSql.get.get(RELAY_LAST_EXIT_SETTING_KEY)?.value, null);
|
|
1768
|
+
exitSql.take.run(RELAY_LAST_EXIT_SETTING_KEY);
|
|
1769
|
+
const stoppedAt = Date.parse(String(exit?.stoppedAt || ''));
|
|
1770
|
+
if (exit && !(Number.isFinite(stoppedAt) && now() - stoppedAt <= RELAY_LAST_EXIT_MAX_AGE_MS)) exit = null;
|
|
1771
|
+
}
|
|
1772
|
+
const noWake = new Set((Array.isArray(exit?.noWake) ? exit.noWake : []).map(toText));
|
|
1773
|
+
const rows = activeRows().filter((row) => !isMirror(row)
|
|
1774
|
+
&& !isServedKey(row.watcher_conversation_id)
|
|
1775
|
+
&& wants(row, { type: RELAY_EVENT_RELAY_RESTARTED_TYPE })
|
|
1776
|
+
&& isLive(conversation(row.watcher_conversation_id)));
|
|
1777
|
+
const held = rows.filter((row) => noWake.has(row.watcher_conversation_id));
|
|
1778
|
+
skipped = [...new Set(held.map((row) => row.watcher_conversation_id))];
|
|
1779
|
+
for (const row of held) {
|
|
1780
|
+
if (Number(row.once || 0) === 1 && endSubscription(row.id, 'held-back')) heldBack.push(row.id);
|
|
1781
|
+
}
|
|
1782
|
+
const woken = rows.filter((row) => !noWake.has(row.watcher_conversation_id));
|
|
1783
|
+
if (!woken.length) return;
|
|
1784
|
+
recorded = enqueueForRows(woken, buildEvent(RELAY_EVENT_RELAY_RESTARTED_TYPE, '', restartedDetail(exit, startedAt)), '');
|
|
1785
|
+
})();
|
|
1786
|
+
if (recorded || skipped.length) {
|
|
1787
|
+
log(`relay started: ${recorded} watcher${recorded === 1 ? '' : 's'} told${skipped.length ? `; not woken (answering relay_restarted when this restart was requested): ${skipped.map(shortId).join(', ')}` : ''}`
|
|
1788
|
+
+ `${heldBack.length ? `; one-shot ${heldBack.join(', ')} ended (held-back)` : ''}`);
|
|
1789
|
+
}
|
|
1790
|
+
if (heldBack.length) broadcast();
|
|
1791
|
+
return { recorded, skipped };
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1602
1794
|
// ─── Hooks ─────────────────────────────────────────────────────────────────
|
|
1603
1795
|
|
|
1604
1796
|
function isRelayEventTurn(queueMessageId) {
|
|
@@ -1814,7 +2006,7 @@ export function createRelayEventService({
|
|
|
1814
2006
|
}
|
|
1815
2007
|
busyBySubscription.set(row.id, false);
|
|
1816
2008
|
idleSinceBySubscription.delete(row.id);
|
|
1817
|
-
if (!wants(row, 'all_idle')) continue;
|
|
2009
|
+
if (!wants(row, { type: 'all_idle' })) continue;
|
|
1818
2010
|
const count = toText(row.scope) === 'all' ? null : parseJson(row.session_ids_json, []).length;
|
|
1819
2011
|
const event = buildEvent('all_idle', '', { scope: toText(row.scope), sessionCount: count, subscriptionId: row.id });
|
|
1820
2012
|
enqueueForRows([row], event, '');
|
|
@@ -1832,7 +2024,7 @@ export function createRelayEventService({
|
|
|
1832
2024
|
function evaluateStalled() {
|
|
1833
2025
|
const at = now();
|
|
1834
2026
|
for (const row of localWatchRows()) {
|
|
1835
|
-
if (!wants(row, 'stalled')) continue;
|
|
2027
|
+
if (!wants(row, { type: 'stalled' })) continue;
|
|
1836
2028
|
const limitMs = (Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault) * 60_000;
|
|
1837
2029
|
for (const sessionId of candidateSessions(row)) {
|
|
1838
2030
|
const view = describe(sessionId);
|
|
@@ -1944,6 +2136,10 @@ export function createRelayEventService({
|
|
|
1944
2136
|
noteQuestion,
|
|
1945
2137
|
noteTaskEdge,
|
|
1946
2138
|
noteConversationGone,
|
|
2139
|
+
noteRestartRequested,
|
|
2140
|
+
noteRestartCancelled,
|
|
2141
|
+
noteRelayExit,
|
|
2142
|
+
noteRelayStarted,
|
|
1947
2143
|
afterStateChange,
|
|
1948
2144
|
sweep,
|
|
1949
2145
|
start,
|
|
@@ -14,6 +14,7 @@ const DEFAULT_RELAY_TOOL_GUIDANCE = [
|
|
|
14
14
|
'For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.',
|
|
15
15
|
'Note: shutdown is queued and only completes when the current turn finishes, so it is pointless to wait for it to interrupt an active turn.',
|
|
16
16
|
'Use `restart: true` in the request body when the user wants a real relay restart rather than a plain shutdown. Example request body: `{ "reason": "manual-restart", "requestedBy": "localhost-api", "restart": true }`.',
|
|
17
|
+
'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.',
|
|
17
18
|
].join(' ');
|
|
18
19
|
|
|
19
20
|
function loadRelayToolGuidance() {
|
|
@@ -32,6 +33,10 @@ function loadRelayToolGuidance() {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const RELAY_TOOL_GUIDANCE = loadRelayToolGuidance();
|
|
36
|
+
// Prompts stored before the `immediate` line was added carry the guidance
|
|
37
|
+
// without it.
|
|
38
|
+
const IMMEDIATE_GUIDANCE_LINE = 'Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.';
|
|
39
|
+
const PREVIOUS_RELAY_TOOL_GUIDANCE = RELAY_TOOL_GUIDANCE.replace(` ${IMMEDIATE_GUIDANCE_LINE}`, '');
|
|
35
40
|
|
|
36
41
|
const MODE_MARKERS = {
|
|
37
42
|
plan: '[Relay mode: plan]',
|
|
@@ -117,6 +122,8 @@ function buildPromptPrefixPatterns(mode) {
|
|
|
117
122
|
return [
|
|
118
123
|
new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
|
|
119
124
|
new RegExp(`^${whitespaceFlexiblePattern(legacyFullPrefix)}\\s*`, 'i'),
|
|
125
|
+
new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
|
|
126
|
+
new RegExp(`^${whitespaceFlexiblePattern(`${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${PREVIOUS_RELAY_TOOL_GUIDANCE}`)}\\s*`, 'i'),
|
|
120
127
|
new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
|
|
121
128
|
];
|
|
122
129
|
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a relay restart (or shutdown) waits for, holds and leaves running,
|
|
5
|
+
* read from the state the relay already has:
|
|
6
|
+
*
|
|
7
|
+
* - waitingFor: the sessions with a processing turn. A queued restart waits
|
|
8
|
+
* for these and only these (relay-shutdown-gate.mjs); "Restart now" does
|
|
9
|
+
* not wait for them.
|
|
10
|
+
* - held: pending and parked messages. While a restart is queued none of
|
|
11
|
+
* them is handed to a worker; they run after it.
|
|
12
|
+
* - backgroundTasks: sessions with running background work of a live
|
|
13
|
+
* worker (shell, monitor, sub-agents; the task panel's data, through
|
|
14
|
+
* background-work-view.mjs), each with whether it survives.
|
|
15
|
+
*
|
|
16
|
+
* Whether background work survives is a property of the session workers,
|
|
17
|
+
* not of the relay runtime: every worker is its own process (tmux, a
|
|
18
|
+
* detached process, a console window on Windows) and keeps its tasks when
|
|
19
|
+
* the runtime exits. A restart (exit 75) is taken by the relay's own
|
|
20
|
+
* supervisor or by the Copilot CLI extension, which start a new runtime;
|
|
21
|
+
* the workers report back to it. A shutdown under a systemd service is
|
|
22
|
+
* different: the service stops, and systemd stops every process of the
|
|
23
|
+
* service, which takes the workers with it unless they live outside it (a
|
|
24
|
+
* tmux session on a tmux server the user started elsewhere survives). The
|
|
25
|
+
* relay cannot tell which, so they "may stop". A Claude Cloud session runs at
|
|
26
|
+
* Anthropic and is never affected. `oar restart` and `oar stop` through
|
|
27
|
+
* systemd do not come through here at all.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from 'node:fs';
|
|
31
|
+
|
|
32
|
+
const MAX_ROWS = 50;
|
|
33
|
+
|
|
34
|
+
function toText(value) {
|
|
35
|
+
return String(value ?? '').trim();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How this relay process is run, from its environment and argv: whether a
|
|
40
|
+
* systemd service runs it, and whether an outer supervisor (the Copilot CLI
|
|
41
|
+
* extension, `--supervised`) owns its restarts.
|
|
42
|
+
*/
|
|
43
|
+
export function detectRelayHosting({
|
|
44
|
+
env = process.env,
|
|
45
|
+
argv = process.argv,
|
|
46
|
+
platform = process.platform,
|
|
47
|
+
readCgroup = () => fs.readFileSync('/proc/self/cgroup', 'utf8'),
|
|
48
|
+
} = {}) {
|
|
49
|
+
// systemd sets INVOCATION_ID for the processes of a unit, and a service's
|
|
50
|
+
// cgroup ends in `.service` (a terminal's shell sits in a `.scope`).
|
|
51
|
+
let systemd = false;
|
|
52
|
+
if (platform === 'linux' && toText(env?.INVOCATION_ID)) {
|
|
53
|
+
let cgroup = '';
|
|
54
|
+
try { cgroup = String(readCgroup() || ''); } catch {}
|
|
55
|
+
systemd = cgroup.split('\n').some((line) => /\.service\/?$/.test(line.trim()));
|
|
56
|
+
}
|
|
57
|
+
const supervised = Array.isArray(argv) && argv.includes('--supervised');
|
|
58
|
+
return { systemd, supervised, platform };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the session workers outlive this action, and why, in one line each. */
|
|
62
|
+
export function describeRestartSurvival({ action = 'restart', hosting = {} } = {}) {
|
|
63
|
+
const restart = action === 'restart';
|
|
64
|
+
if (restart) {
|
|
65
|
+
return {
|
|
66
|
+
action: 'restart',
|
|
67
|
+
workersSurvive: true,
|
|
68
|
+
summary: 'Session workers are separate processes and keep running through a relay restart, with their background tasks; '
|
|
69
|
+
+ 'they report back to the new relay within a minute.',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (hosting.systemd) {
|
|
73
|
+
return {
|
|
74
|
+
action: 'shutdown',
|
|
75
|
+
workersSurvive: false,
|
|
76
|
+
summary: 'The relay runs as a systemd service: shutting it down stops the service and every process in it, '
|
|
77
|
+
+ 'so session workers and their background tasks may stop too (a worker in a tmux session the service did not start keeps running).',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
action: 'shutdown',
|
|
82
|
+
workersSurvive: true,
|
|
83
|
+
summary: 'Session workers are separate processes and keep running while the relay is down, with their background tasks; '
|
|
84
|
+
+ 'they report back when it starts again.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The picture for one action. `queueRows` is the queue grouped by
|
|
90
|
+
* conversation and status ({ conversation_id, status, cnt, title });
|
|
91
|
+
* `backgroundBlockers` the `background` rows of background-work-view.mjs.
|
|
92
|
+
*/
|
|
93
|
+
export function buildRestartPicture({
|
|
94
|
+
action = 'restart',
|
|
95
|
+
queueRows = [],
|
|
96
|
+
backgroundBlockers = [],
|
|
97
|
+
// Tasks held since a relay restart (background-task-store.mjs
|
|
98
|
+
// `listRestored`, mapped to conversations): `{ conversationId, description }`.
|
|
99
|
+
heldTasks = [],
|
|
100
|
+
hosting = {},
|
|
101
|
+
providerOf = () => '',
|
|
102
|
+
titleOf = (conversationId) => toText(conversationId).slice(0, 8),
|
|
103
|
+
} = {}) {
|
|
104
|
+
const survival = describeRestartSurvival({ action, hosting });
|
|
105
|
+
const turns = new Map();
|
|
106
|
+
const held = new Map();
|
|
107
|
+
for (const row of Array.isArray(queueRows) ? queueRows : []) {
|
|
108
|
+
const conversationId = toText(row?.conversation_id);
|
|
109
|
+
const count = Number(row?.cnt || 0);
|
|
110
|
+
if (!conversationId || !(count > 0)) continue;
|
|
111
|
+
const status = toText(row?.status);
|
|
112
|
+
const target = status === 'processing' ? turns : (status === 'pending' || status === 'parked') ? held : null;
|
|
113
|
+
if (!target) continue;
|
|
114
|
+
const entry = target.get(conversationId) || { conversationId, title: toText(row?.title) || titleOf(conversationId), count: 0 };
|
|
115
|
+
entry.count += count;
|
|
116
|
+
target.set(conversationId, entry);
|
|
117
|
+
}
|
|
118
|
+
const waitingFor = [...turns.values()].slice(0, MAX_ROWS).map((entry) => ({
|
|
119
|
+
conversationId: entry.conversationId,
|
|
120
|
+
title: entry.title,
|
|
121
|
+
turns: entry.count,
|
|
122
|
+
}));
|
|
123
|
+
const heldConversations = [...held.values()];
|
|
124
|
+
const backgroundTasks = (Array.isArray(backgroundBlockers) ? backgroundBlockers : [])
|
|
125
|
+
.filter((blocker) => blocker && blocker.kind === 'background' && toText(blocker.conversationId))
|
|
126
|
+
.slice(0, MAX_ROWS)
|
|
127
|
+
.map((blocker) => {
|
|
128
|
+
const conversationId = toText(blocker.conversationId);
|
|
129
|
+
const cloud = toText(providerOf(conversationId)).toLowerCase() === 'claude-cloud';
|
|
130
|
+
const survives = cloud || survival.workersSurvive;
|
|
131
|
+
return {
|
|
132
|
+
conversationId,
|
|
133
|
+
title: toText(blocker.title) || titleOf(conversationId),
|
|
134
|
+
count: Number(blocker.count) > 0 ? Math.trunc(Number(blocker.count)) : 1,
|
|
135
|
+
detail: toText(blocker.detail).slice(0, 400),
|
|
136
|
+
survives,
|
|
137
|
+
why: cloud
|
|
138
|
+
? 'runs in the cloud'
|
|
139
|
+
: survives ? 'its worker keeps running' : 'its worker may stop with the service',
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
// Held since the last restart: neither running nor ended as far as the
|
|
143
|
+
// relay knows, until the worker's set confirms them. Whether they survive
|
|
144
|
+
// the next one is not known either.
|
|
145
|
+
const listed = new Set(backgroundTasks.map((task) => task.conversationId));
|
|
146
|
+
const unconfirmed = new Map();
|
|
147
|
+
for (const entry of Array.isArray(heldTasks) ? heldTasks : []) {
|
|
148
|
+
const conversationId = toText(entry?.conversationId);
|
|
149
|
+
if (!conversationId || listed.has(conversationId)) continue;
|
|
150
|
+
const list = unconfirmed.get(conversationId) || [];
|
|
151
|
+
list.push(toText(entry?.description) || 'task');
|
|
152
|
+
unconfirmed.set(conversationId, list);
|
|
153
|
+
}
|
|
154
|
+
for (const [conversationId, descriptions] of unconfirmed) {
|
|
155
|
+
if (backgroundTasks.length >= MAX_ROWS) break;
|
|
156
|
+
const count = descriptions.length;
|
|
157
|
+
backgroundTasks.push({
|
|
158
|
+
conversationId,
|
|
159
|
+
title: titleOf(conversationId),
|
|
160
|
+
count,
|
|
161
|
+
detail: `${count} task${count === 1 ? '' : 's'} not yet confirmed since the last relay restart: ${descriptions.slice(0, 3).map((text) => text.slice(0, 80)).join('; ')}`,
|
|
162
|
+
survives: null,
|
|
163
|
+
why: 'its worker has not confirmed it since the last restart',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
waitingFor,
|
|
168
|
+
held: {
|
|
169
|
+
count: heldConversations.reduce((sum, entry) => sum + entry.count, 0),
|
|
170
|
+
conversations: heldConversations.slice(0, MAX_ROWS).map((entry) => ({
|
|
171
|
+
conversationId: entry.conversationId,
|
|
172
|
+
title: entry.title,
|
|
173
|
+
count: entry.count,
|
|
174
|
+
})),
|
|
175
|
+
},
|
|
176
|
+
backgroundTasks,
|
|
177
|
+
survival,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The live collector over the relay's database and background-work view. */
|
|
182
|
+
export function createRestartPictureCollector({
|
|
183
|
+
db,
|
|
184
|
+
backgroundWorkView = null,
|
|
185
|
+
// () => [{ conversationId, description }]: tasks held since a restart.
|
|
186
|
+
listHeldTasks = () => [],
|
|
187
|
+
hosting = detectRelayHosting(),
|
|
188
|
+
providerOf = () => '',
|
|
189
|
+
titleOf = undefined,
|
|
190
|
+
} = {}) {
|
|
191
|
+
let listQueue = null;
|
|
192
|
+
try {
|
|
193
|
+
listQueue = db?.prepare(`
|
|
194
|
+
SELECT q.conversation_id, q.status, COUNT(*) AS cnt, MAX(c.title) AS title
|
|
195
|
+
FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
|
|
196
|
+
WHERE q.status IN ('pending','processing','parked')
|
|
197
|
+
GROUP BY q.conversation_id, q.status
|
|
198
|
+
`) || null;
|
|
199
|
+
} catch {}
|
|
200
|
+
return function collectRestartPicture({ action = 'restart' } = {}) {
|
|
201
|
+
let queueRows = [];
|
|
202
|
+
try { queueRows = listQueue?.all() || []; } catch {}
|
|
203
|
+
let backgroundBlockers = [];
|
|
204
|
+
try { backgroundBlockers = backgroundWorkView?.backgroundBlockers?.() || []; } catch {}
|
|
205
|
+
let heldTasks = [];
|
|
206
|
+
try { heldTasks = listHeldTasks() || []; } catch {}
|
|
207
|
+
return buildRestartPicture({ action, queueRows, backgroundBlockers, heldTasks, hosting, providerOf, ...(titleOf ? { titleOf } : {}) });
|
|
208
|
+
};
|
|
209
|
+
}
|