@oar-sh/oar 0.9.11 → 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 +332 -0
- package/README.md +10 -10
- package/package.json +1 -1
- package/server/README.md +201 -12
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +57 -4
- package/server/claude-worker/claude-session-process.mjs +179 -0
- package/server/claude-worker/claude-session-worker.mjs +17 -0
- package/server/claude-worker/task-output-discovery.mjs +152 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-sdk-session-process.mjs +169 -3
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +2 -0
- package/server/copilot-worker/copilot-shell-output-mirror.mjs +94 -0
- package/server/copilot-worker/copilot-task-settle.mjs +96 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/acp-host-services.mjs +48 -6
- package/server/grok-worker/grok-background-tasks.mjs +248 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +3 -1
- package/server/grok-worker/grok-session-worker.mjs +33 -1
- package/server/grok-worker/grok-turn-runner.mjs +11 -0
- package/server/migrations/0017-conversation-history-indexes.mjs +89 -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 +70 -3
- package/server/public/app/background-tasks-view.mjs +334 -16
- package/server/public/app/bootstrap.js +102 -5
- package/server/public/app/claude-background-command-limit-options.mjs +21 -0
- package/server/public/app/conversation-payload-cache.mjs +357 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-turn-state.mjs +1 -1
- package/server/public/app/conversation-view.js +462 -8
- package/server/public/app/conversation-window-retention.mjs +96 -0
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +259 -67
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +20 -6
- 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 +162 -2
- package/server/public/app/socket-handlers.js +59 -7
- package/server/public/app/store.js +25 -0
- package/server/public/app/subagent-viewer.mjs +7 -1
- package/server/public/app/task-console-render.mjs +401 -0
- package/server/public/app/task-console-watch.mjs +42 -0
- package/server/public/app/task-console.mjs +730 -0
- package/server/public/app/task-output-text.mjs +243 -0
- package/server/public/app/transcript-island.mjs +73 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +42 -8
- package/server/public/app/transcript-jump-bottom.mjs +68 -11
- package/server/public/app/transcript-reading-anchor.mjs +256 -0
- package/server/public/app/transcript-reload-freshness.mjs +17 -1
- package/server/public/app/transcript-viewport-anchor.mjs +20 -1
- package/server/public/index.html +306 -6
- package/server/relay-tools.md +6 -0
- package/server/repositories/message-repository.mjs +11 -0
- package/server/repositories/question-repository.mjs +4 -0
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/routes/messages-routes.mjs +397 -23
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +208 -66
- package/server/server-runtime.mjs +269 -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 +470 -6
- package/server/services/cli-process-runner.mjs +4 -21
- package/server/services/conversation-history-page.mjs +199 -0
- 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 +18 -4
- 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 +316 -20
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +6 -2
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/task-output-rooms.mjs +259 -0
- package/server/services/task-output-service.mjs +761 -0
- package/shared/claude-background-command-limit.mjs +83 -0
- package/shared/relay-events.mjs +55 -2
- package/shared/remote-relay-contract.mjs +47 -10
- package/shared/task-output-text.mjs +850 -0
- package/shared/worker-runtime/task-output-spool.mjs +181 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
|
@@ -57,13 +57,18 @@ import {
|
|
|
57
57
|
RELAY_EVENT_LINK_LOST_TYPE,
|
|
58
58
|
RELAY_EVENT_LINK_RESTORED_TYPE,
|
|
59
59
|
RELAY_EVENT_MESSAGE_KIND,
|
|
60
|
+
RELAY_EVENT_SUBAGENT_FINISHED_TYPE,
|
|
61
|
+
RELAY_EVENT_SUBSCRIBABLE_TYPES,
|
|
60
62
|
RELAY_EVENT_TYPES,
|
|
61
63
|
RELAY_EVENT_VERSION,
|
|
62
64
|
buildRelayEventMessageText,
|
|
63
65
|
compileRelayEventMatch,
|
|
66
|
+
effectiveRelayEventTypes,
|
|
67
|
+
isLegacyDefaultRelayEventTypes,
|
|
64
68
|
isRelayEventType,
|
|
65
69
|
isUrgentRelayEventType,
|
|
66
70
|
relayEventMatchText,
|
|
71
|
+
relayEventTypesTake,
|
|
67
72
|
} from '../../shared/relay-events.mjs';
|
|
68
73
|
|
|
69
74
|
export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
|
|
@@ -403,6 +408,11 @@ export function createRelayEventService({
|
|
|
403
408
|
return !!row && Number(row.archived || 0) !== 1 && toText(row.status) !== 'deleted';
|
|
404
409
|
}
|
|
405
410
|
|
|
411
|
+
/** A subscription's events; the old default list counts as today's default. */
|
|
412
|
+
function storedTypes(row) {
|
|
413
|
+
return effectiveRelayEventTypes(parseJson(row?.events_json, []));
|
|
414
|
+
}
|
|
415
|
+
|
|
406
416
|
function shapeSubscription(row) {
|
|
407
417
|
if (!row) return null;
|
|
408
418
|
const scope = toText(row.scope) === 'all' ? 'all' : 'sessions';
|
|
@@ -431,7 +441,10 @@ export function createRelayEventService({
|
|
|
431
441
|
sessions: scope === 'all'
|
|
432
442
|
? 'all'
|
|
433
443
|
: sessionIds.map((id) => ({ id, title: mirror ? (remoteTitles.get(id) || null) : (toText(conversation(id)?.title) || null) })),
|
|
434
|
-
|
|
444
|
+
// A mirror shows what the paired relay applies; a subscription here,
|
|
445
|
+
// stored with the old default, the default that applies now.
|
|
446
|
+
events: (mirror ? parseJson(row.events_json, []) : storedTypes(row))
|
|
447
|
+
.filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)),
|
|
435
448
|
once: Number(row.once || 0) === 1,
|
|
436
449
|
...(toText(row.match) ? { match: row.match } : {}),
|
|
437
450
|
stalledMinutes: Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault,
|
|
@@ -504,9 +517,13 @@ export function createRelayEventService({
|
|
|
504
517
|
throw new RelayEventError('notFound', `No live session with id ${missing.join(', ')} on this relay (archived sessions cannot be watched).`, { sessions: missing });
|
|
505
518
|
}
|
|
506
519
|
const types = Array.isArray(events) && events.length
|
|
507
|
-
? [...new Set(events.map(toText).filter((type) =>
|
|
520
|
+
? [...new Set(events.map(toText).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type)))]
|
|
508
521
|
: [...RELAY_EVENT_DEFAULT_TYPES];
|
|
509
|
-
if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${
|
|
522
|
+
if (!types.length) throw new RelayEventError('invalid', `events must name some of: ${RELAY_EVENT_SUBSCRIBABLE_TYPES.join(', ')}`);
|
|
523
|
+
// Named exactly as the old default was stored, this list would be read
|
|
524
|
+
// as today's default and lose its task_finished (effectiveRelayEventTypes);
|
|
525
|
+
// subagent_finished beside it changes nothing it takes and keeps it named.
|
|
526
|
+
if (isLegacyDefaultRelayEventTypes(types)) types.push(RELAY_EVENT_SUBAGENT_FINISHED_TYPE);
|
|
510
527
|
const compiled = compileRelayEventMatch(match);
|
|
511
528
|
if (!compiled.ok) throw new RelayEventError('invalid', compiled.error);
|
|
512
529
|
const stalled = Number.isFinite(Number(stalledMinutes)) && stalledMinutes !== null && stalledMinutes !== ''
|
|
@@ -771,8 +788,9 @@ export function createRelayEventService({
|
|
|
771
788
|
return parseJson(row.session_ids_json, []).map(toText).includes(sessionId);
|
|
772
789
|
}
|
|
773
790
|
|
|
774
|
-
|
|
775
|
-
|
|
791
|
+
/** `event` is `{ type, detail }`: a task_finished's kind decides under subagent_finished. */
|
|
792
|
+
function wants(row, event) {
|
|
793
|
+
return relayEventTypesTake(storedTypes(row), event);
|
|
776
794
|
}
|
|
777
795
|
|
|
778
796
|
/** `match` filters the events it can be tried on; the others pass. */
|
|
@@ -807,7 +825,7 @@ export function createRelayEventService({
|
|
|
807
825
|
if (!available || !RELAY_EVENT_TYPES.includes(type)) return 0;
|
|
808
826
|
const id = toText(sessionId);
|
|
809
827
|
if (!id) return 0;
|
|
810
|
-
const rows = localWatchRows().filter((row) => wants(row, type) && subscriptionCovers(row, id));
|
|
828
|
+
const rows = localWatchRows().filter((row) => wants(row, { type, detail }) && subscriptionCovers(row, id));
|
|
811
829
|
if (!rows.length) return 0;
|
|
812
830
|
const event = buildEvent(type, id, detail);
|
|
813
831
|
return enqueueForRows(rows, event, id);
|
|
@@ -1271,7 +1289,7 @@ export function createRelayEventService({
|
|
|
1271
1289
|
const sessions = all ? [] : (Array.isArray(remote.sessions) ? remote.sessions : [])
|
|
1272
1290
|
.map((session) => ({ id: toText(session?.id), title: toText(session?.title) || null }))
|
|
1273
1291
|
.filter((session) => session.id);
|
|
1274
|
-
const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) =>
|
|
1292
|
+
const types = (Array.isArray(remote.events) ? remote.events : []).filter((type) => RELAY_EVENT_SUBSCRIBABLE_TYPES.includes(type));
|
|
1275
1293
|
const id = `sub_${randomBytes(5).toString('hex')}`;
|
|
1276
1294
|
const at = isoNow();
|
|
1277
1295
|
remoteSql.insertMirror.run(
|
|
@@ -1814,7 +1832,7 @@ export function createRelayEventService({
|
|
|
1814
1832
|
}
|
|
1815
1833
|
busyBySubscription.set(row.id, false);
|
|
1816
1834
|
idleSinceBySubscription.delete(row.id);
|
|
1817
|
-
if (!wants(row, 'all_idle')) continue;
|
|
1835
|
+
if (!wants(row, { type: 'all_idle' })) continue;
|
|
1818
1836
|
const count = toText(row.scope) === 'all' ? null : parseJson(row.session_ids_json, []).length;
|
|
1819
1837
|
const event = buildEvent('all_idle', '', { scope: toText(row.scope), sessionCount: count, subscriptionId: row.id });
|
|
1820
1838
|
enqueueForRows([row], event, '');
|
|
@@ -1832,7 +1850,7 @@ export function createRelayEventService({
|
|
|
1832
1850
|
function evaluateStalled() {
|
|
1833
1851
|
const at = now();
|
|
1834
1852
|
for (const row of localWatchRows()) {
|
|
1835
|
-
if (!wants(row, 'stalled')) continue;
|
|
1853
|
+
if (!wants(row, { type: 'stalled' })) continue;
|
|
1836
1854
|
const limitMs = (Number(row.stalled_minutes) || RELAY_EVENT_LIMITS.stalledMinutesDefault) * 60_000;
|
|
1837
1855
|
for (const sessionId of candidateSessions(row)) {
|
|
1838
1856
|
const view = describe(sessionId);
|
|
@@ -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]',
|
|
@@ -100,21 +105,30 @@ function whitespaceFlexiblePattern(value) {
|
|
|
100
105
|
.join('\\s+');
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
// The patterns depend only on the mode, so they are built once per mode and
|
|
109
|
+
// reused: a long conversation sanitises every message on each history load.
|
|
110
|
+
const PROMPT_PREFIX_PATTERN_CACHE = new Map();
|
|
111
|
+
|
|
103
112
|
function buildPromptPrefixPatterns(mode) {
|
|
104
113
|
const requestedMode = String(mode || '').trim().toLowerCase();
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
const cacheKey = requestedMode && MODE_MARKERS[requestedMode] ? requestedMode : '';
|
|
115
|
+
const cached = PROMPT_PREFIX_PATTERN_CACHE.get(cacheKey);
|
|
116
|
+
if (cached) return cached;
|
|
117
|
+
const modes = cacheKey ? [cacheKey] : Object.keys(MODE_MARKERS);
|
|
118
|
+
const patterns = modes.flatMap((key) => {
|
|
109
119
|
const marker = MODE_MARKERS[key];
|
|
110
120
|
const currentFullPrefix = `${marker} ${MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
|
|
111
121
|
const legacyFullPrefix = `${marker} ${LEGACY_MODE_INSTRUCTIONS[key]} ${RELAY_TOOL_GUIDANCE}`;
|
|
112
122
|
return [
|
|
113
123
|
new RegExp(`^${whitespaceFlexiblePattern(currentFullPrefix)}\\s*`, 'i'),
|
|
114
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'),
|
|
115
127
|
new RegExp(`^${whitespaceFlexiblePattern(marker)}\\s*`, 'i'),
|
|
116
128
|
];
|
|
117
129
|
});
|
|
130
|
+
PROMPT_PREFIX_PATTERN_CACHE.set(cacheKey, patterns);
|
|
131
|
+
return patterns;
|
|
118
132
|
}
|
|
119
133
|
|
|
120
134
|
function stripAttachmentPromptArtifacts(text) {
|
|
@@ -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
|
+
}
|