@oar-sh/oar 0.9.9 → 0.9.10
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 +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +36 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +61 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What each session of this relay is doing right now: a turn, background
|
|
5
|
+
* tasks only, or nothing, with the tasks themselves and when anything last
|
|
6
|
+
* moved.
|
|
7
|
+
*
|
|
8
|
+
* `list_sessions` / `read_session` read it as `state` and `tasks[]`; the
|
|
9
|
+
* relay event service reads its edges (a task started or ended) and its quiet
|
|
10
|
+
* time (stalled). Memory only: the background-task set is republished by the
|
|
11
|
+
* workers on every change, sub-agent runs are read from the database, and a
|
|
12
|
+
* restart forgets only the recently finished tasks and the activity clock
|
|
13
|
+
* (which then starts at the restart, so nothing reads as stalled at boot).
|
|
14
|
+
*
|
|
15
|
+
* Sources:
|
|
16
|
+
* - `noteTaskSet` — every POST /api/background-tasks (REPLACE semantics):
|
|
17
|
+
* a new task id is a start, a task gone from the set has ended, a changed
|
|
18
|
+
* task (tool call, summary, tokens) is activity.
|
|
19
|
+
* - `noteTaskSettled` — how a task ended, where the worker says so (the
|
|
20
|
+
* Claude worker's task_notification).
|
|
21
|
+
* - `noteSubagentRun` — every sub-agent run status the relay stores; a
|
|
22
|
+
* background run's terminal status is how its task ended, and a
|
|
23
|
+
* background run with no task in the set (Copilot, Cursor) ends here.
|
|
24
|
+
* - `touch` — any activity, thought, stream or timeline post.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
|
|
28
|
+
|
|
29
|
+
export const SESSION_STATES = Object.freeze(['running_turn', 'background_only', 'idle']);
|
|
30
|
+
|
|
31
|
+
// How long a task gone from the set waits for the word on how it ended.
|
|
32
|
+
export const TASK_END_GRACE_MS = 5_000;
|
|
33
|
+
// A `running` run row not touched for this long is not running work: its
|
|
34
|
+
// worker's record of its end never arrived (live: rows months old still
|
|
35
|
+
// `running`, counted as a running task by every reader, so a subscription
|
|
36
|
+
// to all sessions never saw all_idle and reported the session stalled
|
|
37
|
+
// again and again). The sweep in stale-subagent-run-sweep.mjs closes such
|
|
38
|
+
// rows; this bound keeps them out of the state meanwhile.
|
|
39
|
+
export const STALE_RUNNING_RUN_MAX_AGE_MS = 24 * 60 * 60_000;
|
|
40
|
+
const RECENT_TASKS_MAX = 5;
|
|
41
|
+
const RECENT_TASK_TTL_MS = 60 * 60_000;
|
|
42
|
+
const SHELL_TASK_TYPES = new Set(['local_bash', 'bash', 'shell', 'monitor', 'local_monitor']);
|
|
43
|
+
const EXIT_CODE_PATTERN = /exit(?:ed)?(?: with)?(?: code| status)?[:\s]+(-?\d{1,3})\b/i;
|
|
44
|
+
|
|
45
|
+
function toText(value) {
|
|
46
|
+
return String(value ?? '').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isoOf(ms) {
|
|
50
|
+
return Number.isFinite(ms) && ms > 0 ? new Date(ms).toISOString() : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function msOf(value) {
|
|
54
|
+
if (value === null || value === undefined || value === '') return 0;
|
|
55
|
+
const number = Number(value);
|
|
56
|
+
if (Number.isFinite(number) && number > 0) return number;
|
|
57
|
+
const parsed = Date.parse(String(value));
|
|
58
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function taskKindOf(taskType) {
|
|
62
|
+
return SHELL_TASK_TYPES.has(toText(taskType).toLowerCase()) ? 'shell' : 'subagent';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** An exit code a task summary names ("Exit code 2", "exited with 1"), else null. */
|
|
66
|
+
export function exitCodeFromSummary(summary) {
|
|
67
|
+
const match = EXIT_CODE_PATTERN.exec(String(summary || ''));
|
|
68
|
+
if (!match) return null;
|
|
69
|
+
const code = Number(match[1]);
|
|
70
|
+
return Number.isInteger(code) ? code : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The status words of a task: running, completed, failed, stopped. */
|
|
74
|
+
export function normalizeTaskStatus(status) {
|
|
75
|
+
const value = normalizeSubagentRunStatus(status);
|
|
76
|
+
if (value === 'cancelled') return 'stopped';
|
|
77
|
+
if (value === 'running' || value === 'completed' || value === 'failed') return value;
|
|
78
|
+
return value ? 'completed' : 'running';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function taskSignature(task) {
|
|
82
|
+
return JSON.stringify([
|
|
83
|
+
task.summary || null,
|
|
84
|
+
task.lastToolName || null,
|
|
85
|
+
task.lastToolCall || null,
|
|
86
|
+
task.totalTokens ?? null,
|
|
87
|
+
task.workflowProgress ? JSON.stringify(task.workflowProgress).length : 0,
|
|
88
|
+
]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createSessionActivityTracker({
|
|
92
|
+
db = null,
|
|
93
|
+
now = () => Date.now(),
|
|
94
|
+
setTimer = (fn, ms) => {
|
|
95
|
+
const timer = setTimeout(fn, ms);
|
|
96
|
+
timer.unref?.();
|
|
97
|
+
return timer;
|
|
98
|
+
},
|
|
99
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
100
|
+
onEdge = () => {},
|
|
101
|
+
logger = console,
|
|
102
|
+
} = {}) {
|
|
103
|
+
const startedAtMs = now();
|
|
104
|
+
// conversationId → { live: Map<taskId, entry>, recent: [], lastActivityAt }
|
|
105
|
+
const sessions = new Map();
|
|
106
|
+
// Edges already reported for an end, by `${conversationId}:${key}`.
|
|
107
|
+
const endedKeys = new Map();
|
|
108
|
+
|
|
109
|
+
let listRuns = null;
|
|
110
|
+
let getQueueRows = null;
|
|
111
|
+
if (db) {
|
|
112
|
+
try {
|
|
113
|
+
listRuns = db.prepare(`
|
|
114
|
+
SELECT * FROM subagent_runs
|
|
115
|
+
WHERE conversation_id = ? AND (status = 'running' OR COALESCE(completed_at, updated_at) >= ?)
|
|
116
|
+
AND parent_subagent_id IS NULL
|
|
117
|
+
ORDER BY started_at ASC
|
|
118
|
+
LIMIT 50
|
|
119
|
+
`);
|
|
120
|
+
} catch {}
|
|
121
|
+
try {
|
|
122
|
+
getQueueRows = db.prepare(`
|
|
123
|
+
SELECT id, status, timestamp, processing_at, next_attempt_at, usage_limit_pause
|
|
124
|
+
FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')
|
|
125
|
+
`);
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function session(conversationId) {
|
|
130
|
+
const id = toText(conversationId);
|
|
131
|
+
let entry = sessions.get(id);
|
|
132
|
+
if (!entry) {
|
|
133
|
+
entry = { live: new Map(), recent: [], lastActivityAt: 0 };
|
|
134
|
+
sessions.set(id, entry);
|
|
135
|
+
}
|
|
136
|
+
return entry;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function emitEdge(type, conversationId, task) {
|
|
140
|
+
try {
|
|
141
|
+
onEdge({ type, conversationId, task: publicTask(task) });
|
|
142
|
+
} catch (error) {
|
|
143
|
+
try { logger?.warn?.(`[activity] edge handler failed: ${error?.message || error}`); } catch {}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function markEnded(conversationId, task) {
|
|
148
|
+
const at = now();
|
|
149
|
+
for (const key of [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']) {
|
|
150
|
+
if (key) endedKeys.set(`${conversationId}:${key}`, at);
|
|
151
|
+
}
|
|
152
|
+
if (endedKeys.size > 2000) {
|
|
153
|
+
for (const [key, when] of endedKeys) {
|
|
154
|
+
if (at - when > RECENT_TASK_TTL_MS) endedKeys.delete(key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function wasEnded(conversationId, task) {
|
|
160
|
+
return [task.taskId, task.toolUseId ? `run:${task.toolUseId}` : '']
|
|
161
|
+
.some((key) => key && endedKeys.has(`${conversationId}:${key}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function publicTask(task) {
|
|
165
|
+
return {
|
|
166
|
+
id: task.taskId || task.toolUseId || null,
|
|
167
|
+
kind: task.kind,
|
|
168
|
+
type: task.taskType || null,
|
|
169
|
+
description: task.description || '',
|
|
170
|
+
startedAt: isoOf(task.startedAtMs),
|
|
171
|
+
lastActivityAt: isoOf(task.lastActivityAtMs || task.startedAtMs),
|
|
172
|
+
status: task.status,
|
|
173
|
+
...(task.exitCode !== null && task.exitCode !== undefined ? { exitCode: task.exitCode } : {}),
|
|
174
|
+
...(task.status !== 'running' && task.statusReported === false ? { statusReported: false } : {}),
|
|
175
|
+
...(task.summary ? { summary: task.summary } : {}),
|
|
176
|
+
...(task.endedAtMs ? { endedAt: isoOf(task.endedAtMs) } : {}),
|
|
177
|
+
...(task.toolUseId ? { runId: task.toolUseId } : {}),
|
|
178
|
+
...(task.model ? { model: task.model } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function finishTask(conversationId, task, { status, summary = null, exitCode = null, reported = true } = {}) {
|
|
183
|
+
if (task.graceTimer) {
|
|
184
|
+
clearTimer(task.graceTimer);
|
|
185
|
+
task.graceTimer = null;
|
|
186
|
+
}
|
|
187
|
+
const entry = session(conversationId);
|
|
188
|
+
entry.live.delete(task.taskId);
|
|
189
|
+
if (wasEnded(conversationId, task)) return;
|
|
190
|
+
task.status = normalizeTaskStatus(status || 'completed');
|
|
191
|
+
task.statusReported = reported;
|
|
192
|
+
if (summary) task.summary = toText(summary).slice(0, 500);
|
|
193
|
+
const code = Number.isInteger(exitCode) ? exitCode : exitCodeFromSummary(task.summary);
|
|
194
|
+
task.exitCode = code;
|
|
195
|
+
task.endedAtMs = now();
|
|
196
|
+
markEnded(conversationId, task);
|
|
197
|
+
entry.recent.unshift(task);
|
|
198
|
+
entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
|
|
199
|
+
entry.lastActivityAt = Math.max(entry.lastActivityAt, task.endedAtMs);
|
|
200
|
+
const failed = task.status === 'failed' || task.status === 'stopped' || (code !== null && code !== 0);
|
|
201
|
+
emitEdge(failed ? 'task_failed' : 'task_finished', conversationId, task);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function runStatus(runId) {
|
|
205
|
+
if (!db || !runId) return null;
|
|
206
|
+
try {
|
|
207
|
+
const row = db.prepare(`SELECT status FROM subagent_runs WHERE id = ?`).get(runId);
|
|
208
|
+
return row ? normalizeSubagentRunStatus(row.status) : null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A task left the set: settle it now if its end is known, else after the grace. */
|
|
215
|
+
function taskLeft(conversationId, task) {
|
|
216
|
+
if (task.settled) {
|
|
217
|
+
finishTask(conversationId, task, task.settled);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const status = runStatus(task.toolUseId);
|
|
221
|
+
if (status && isTerminalSubagentRunStatus(status)) {
|
|
222
|
+
finishTask(conversationId, task, { status });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
task.leftAtMs = now();
|
|
226
|
+
task.graceTimer = setTimer(() => {
|
|
227
|
+
task.graceTimer = null;
|
|
228
|
+
const late = runStatus(task.toolUseId);
|
|
229
|
+
if (late && isTerminalSubagentRunStatus(late)) finishTask(conversationId, task, { status: late });
|
|
230
|
+
else finishTask(conversationId, task, { status: 'completed', reported: false });
|
|
231
|
+
}, TASK_END_GRACE_MS);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function noteTaskSet(conversationId, tasks) {
|
|
235
|
+
const id = toText(conversationId);
|
|
236
|
+
if (!id) return;
|
|
237
|
+
const entry = session(id);
|
|
238
|
+
const at = now();
|
|
239
|
+
const seen = new Set();
|
|
240
|
+
for (const raw of Array.isArray(tasks) ? tasks : []) {
|
|
241
|
+
const taskId = toText(raw?.taskId);
|
|
242
|
+
if (!taskId) continue;
|
|
243
|
+
seen.add(taskId);
|
|
244
|
+
const signature = taskSignature(raw);
|
|
245
|
+
const known = entry.live.get(taskId);
|
|
246
|
+
if (known && !known.leftAtMs) {
|
|
247
|
+
if (known.signature !== signature) {
|
|
248
|
+
known.signature = signature;
|
|
249
|
+
known.lastActivityAtMs = at;
|
|
250
|
+
entry.lastActivityAt = at;
|
|
251
|
+
}
|
|
252
|
+
known.description = toText(raw.description) || known.description;
|
|
253
|
+
known.toolUseId = toText(raw.toolUseId) || known.toolUseId;
|
|
254
|
+
known.summary = toText(raw.summary) || known.summary;
|
|
255
|
+
known.model = toText(raw.model) || known.model;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (known?.leftAtMs) {
|
|
259
|
+
// Back in the set after it left: it did not end after all.
|
|
260
|
+
if (known.graceTimer) clearTimer(known.graceTimer);
|
|
261
|
+
known.graceTimer = null;
|
|
262
|
+
known.leftAtMs = 0;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Settled already (its notice came before the set dropped it).
|
|
266
|
+
if (wasEnded(id, { taskId, toolUseId: toText(raw.toolUseId) })) continue;
|
|
267
|
+
const task = {
|
|
268
|
+
taskId,
|
|
269
|
+
toolUseId: toText(raw.toolUseId) || null,
|
|
270
|
+
taskType: toText(raw.taskType),
|
|
271
|
+
kind: taskKindOf(raw.taskType),
|
|
272
|
+
description: toText(raw.description).slice(0, 500),
|
|
273
|
+
startedAtMs: msOf(raw.startedAt) || at,
|
|
274
|
+
lastActivityAtMs: at,
|
|
275
|
+
summary: toText(raw.summary) || null,
|
|
276
|
+
model: toText(raw.model) || null,
|
|
277
|
+
status: 'running',
|
|
278
|
+
signature,
|
|
279
|
+
settled: null,
|
|
280
|
+
graceTimer: null,
|
|
281
|
+
leftAtMs: 0,
|
|
282
|
+
};
|
|
283
|
+
entry.live.set(taskId, task);
|
|
284
|
+
entry.lastActivityAt = at;
|
|
285
|
+
emitEdge('task_started', id, task);
|
|
286
|
+
}
|
|
287
|
+
for (const task of [...entry.live.values()]) {
|
|
288
|
+
if (seen.has(task.taskId) || task.leftAtMs) continue;
|
|
289
|
+
taskLeft(id, task);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function noteTaskSettled(conversationId, { taskId, toolUseId, status, summary, exitCode } = {}) {
|
|
294
|
+
const id = toText(conversationId);
|
|
295
|
+
if (!id) return;
|
|
296
|
+
const entry = session(id);
|
|
297
|
+
const key = toText(taskId);
|
|
298
|
+
const run = toText(toolUseId);
|
|
299
|
+
const task = (key && entry.live.get(key))
|
|
300
|
+
|| [...entry.live.values()].find((candidate) => run && candidate.toolUseId === run)
|
|
301
|
+
|| null;
|
|
302
|
+
const settled = {
|
|
303
|
+
status: normalizeTaskStatus(status || 'completed'),
|
|
304
|
+
summary: toText(summary) || null,
|
|
305
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : null,
|
|
306
|
+
};
|
|
307
|
+
entry.lastActivityAt = now();
|
|
308
|
+
if (!task) return;
|
|
309
|
+
task.settled = settled;
|
|
310
|
+
// Gone from the set already, or about to: settle now either way. A task
|
|
311
|
+
// still listed would leave with this status anyway.
|
|
312
|
+
finishTask(id, task, settled);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function noteSubagentRun(run) {
|
|
316
|
+
const id = toText(run?.conversation_id || run?.conversationId);
|
|
317
|
+
const runId = toText(run?.id || run?.subagentRunId);
|
|
318
|
+
if (!id || !runId) return;
|
|
319
|
+
const entry = session(id);
|
|
320
|
+
entry.lastActivityAt = now();
|
|
321
|
+
const status = normalizeSubagentRunStatus(run?.status);
|
|
322
|
+
if (!isTerminalSubagentRunStatus(status)) return;
|
|
323
|
+
const task = [...entry.live.values()].find((candidate) => candidate.toolUseId === runId) || null;
|
|
324
|
+
if (task) {
|
|
325
|
+
// The run's end is the task's: finish it once it has left the set,
|
|
326
|
+
// which the grace timer is waiting for; a task still listed keeps
|
|
327
|
+
// waiting for its own departure.
|
|
328
|
+
if (task.leftAtMs) finishTask(id, task, { status });
|
|
329
|
+
else task.settled = task.settled || { status };
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const background = Number(run?.background) === 1 || run?.background === true;
|
|
333
|
+
if (!background || toText(run?.parent_subagent_id || run?.parentSubagentId)) return;
|
|
334
|
+
const synthetic = {
|
|
335
|
+
taskId: `run:${runId}`,
|
|
336
|
+
toolUseId: runId,
|
|
337
|
+
taskType: toText(run?.subagent_type) || 'agent',
|
|
338
|
+
kind: 'subagent',
|
|
339
|
+
description: toText(run?.display_name || run?.displayName) || 'sub-agent',
|
|
340
|
+
startedAtMs: msOf(run?.started_at) || now(),
|
|
341
|
+
lastActivityAtMs: now(),
|
|
342
|
+
summary: null,
|
|
343
|
+
model: toText(run?.model) || null,
|
|
344
|
+
status: 'running',
|
|
345
|
+
};
|
|
346
|
+
if (wasEnded(id, synthetic)) return;
|
|
347
|
+
finishTask(id, synthetic, { status });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function touch(conversationId) {
|
|
351
|
+
const id = toText(conversationId);
|
|
352
|
+
if (!id) return;
|
|
353
|
+
session(id).lastActivityAt = now();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function forget(conversationId) {
|
|
357
|
+
const id = toText(conversationId);
|
|
358
|
+
const entry = sessions.get(id);
|
|
359
|
+
if (!entry) return;
|
|
360
|
+
for (const task of entry.live.values()) {
|
|
361
|
+
if (task.graceTimer) clearTimer(task.graceTimer);
|
|
362
|
+
}
|
|
363
|
+
sessions.delete(id);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The worker died: its tasks went with it, with no edge (the error says it). */
|
|
367
|
+
function noteWorkerDied(conversationId) {
|
|
368
|
+
const id = toText(conversationId);
|
|
369
|
+
const entry = sessions.get(id);
|
|
370
|
+
if (!entry) return;
|
|
371
|
+
for (const task of [...entry.live.values()]) {
|
|
372
|
+
if (task.graceTimer) clearTimer(task.graceTimer);
|
|
373
|
+
entry.live.delete(task.taskId);
|
|
374
|
+
task.status = 'stopped';
|
|
375
|
+
task.statusReported = false;
|
|
376
|
+
task.endedAtMs = now();
|
|
377
|
+
markEnded(id, task);
|
|
378
|
+
entry.recent.unshift(task);
|
|
379
|
+
}
|
|
380
|
+
entry.recent.length = Math.min(entry.recent.length, RECENT_TASKS_MAX);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function turnState(conversationId) {
|
|
384
|
+
if (!getQueueRows) return { running: false, sinceMs: 0 };
|
|
385
|
+
let rows = [];
|
|
386
|
+
try {
|
|
387
|
+
rows = getQueueRows.all(conversationId);
|
|
388
|
+
} catch {
|
|
389
|
+
return { running: false, sinceMs: 0 };
|
|
390
|
+
}
|
|
391
|
+
const at = now();
|
|
392
|
+
let running = false;
|
|
393
|
+
let sinceMs = 0;
|
|
394
|
+
for (const row of rows) {
|
|
395
|
+
const held = row.usage_limit_pause && msOf(row.next_attempt_at) > at;
|
|
396
|
+
if (held) continue;
|
|
397
|
+
running = true;
|
|
398
|
+
const since = msOf(row.processing_at) || msOf(row.timestamp);
|
|
399
|
+
sinceMs = Math.max(sinceMs, since);
|
|
400
|
+
}
|
|
401
|
+
return { running, sinceMs };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function runTasks(conversationId, coveredRunIds) {
|
|
405
|
+
if (!listRuns) return [];
|
|
406
|
+
let rows = [];
|
|
407
|
+
try {
|
|
408
|
+
rows = listRuns.all(conversationId, new Date(now() - RECENT_TASK_TTL_MS).toISOString());
|
|
409
|
+
} catch {
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
const staleBefore = now() - STALE_RUNNING_RUN_MAX_AGE_MS;
|
|
413
|
+
const staleRunning = (row) => normalizeTaskStatus(row.status) === 'running'
|
|
414
|
+
&& msOf(row.updated_at || row.started_at) < staleBefore;
|
|
415
|
+
return rows
|
|
416
|
+
.filter((row) => !coveredRunIds.has(toText(row.id)) && !staleRunning(row))
|
|
417
|
+
.map((row) => {
|
|
418
|
+
const status = normalizeTaskStatus(row.status);
|
|
419
|
+
return {
|
|
420
|
+
id: toText(row.task_id) || toText(row.id),
|
|
421
|
+
kind: 'subagent',
|
|
422
|
+
type: toText(row.subagent_type) || null,
|
|
423
|
+
description: toText(row.display_name) || 'sub-agent',
|
|
424
|
+
startedAt: row.started_at || null,
|
|
425
|
+
lastActivityAt: row.updated_at || row.started_at || null,
|
|
426
|
+
status,
|
|
427
|
+
...(status !== 'running' && row.completed_at ? { endedAt: row.completed_at } : {}),
|
|
428
|
+
runId: toText(row.id),
|
|
429
|
+
...(row.model ? { model: row.model } : {}),
|
|
430
|
+
...(Number(row.background) === 1 ? {} : { inTurn: true }),
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* `{ state, tasks, lastActivityAt, turnSince }` of one conversation.
|
|
437
|
+
* `tasks` lists the running ones, then up to five that ended within the
|
|
438
|
+
* hour, newest first.
|
|
439
|
+
*/
|
|
440
|
+
function describe(conversationId) {
|
|
441
|
+
const id = toText(conversationId);
|
|
442
|
+
const entry = sessions.get(id) || null;
|
|
443
|
+
const at = now();
|
|
444
|
+
const live = entry ? [...entry.live.values()].filter((task) => !task.leftAtMs) : [];
|
|
445
|
+
const recent = entry
|
|
446
|
+
? entry.recent.filter((task) => at - (task.endedAtMs || 0) <= RECENT_TASK_TTL_MS)
|
|
447
|
+
: [];
|
|
448
|
+
const covered = new Set([...live, ...recent, ...(entry ? [...entry.live.values()] : [])]
|
|
449
|
+
.map((task) => toText(task.toolUseId))
|
|
450
|
+
.filter(Boolean));
|
|
451
|
+
const fromRuns = runTasks(id, covered);
|
|
452
|
+
const tasks = [
|
|
453
|
+
...live.map(publicTask),
|
|
454
|
+
...fromRuns.filter((task) => task.status === 'running'),
|
|
455
|
+
...[...recent.map(publicTask), ...fromRuns.filter((task) => task.status !== 'running')]
|
|
456
|
+
.sort((a, b) => msOf(b.endedAt) - msOf(a.endedAt))
|
|
457
|
+
.slice(0, RECENT_TASKS_MAX),
|
|
458
|
+
];
|
|
459
|
+
const turn = turnState(id);
|
|
460
|
+
const runningTasks = tasks.filter((task) => task.status === 'running');
|
|
461
|
+
const state = turn.running ? 'running_turn' : (runningTasks.length ? 'background_only' : 'idle');
|
|
462
|
+
const taskActivity = Math.max(0, ...runningTasks.map((task) => msOf(task.lastActivityAt)));
|
|
463
|
+
const lastActivityMs = Math.max(entry?.lastActivityAt || 0, taskActivity, turn.sinceMs, startedAtMs);
|
|
464
|
+
return {
|
|
465
|
+
state,
|
|
466
|
+
tasks,
|
|
467
|
+
runningTaskCount: runningTasks.length,
|
|
468
|
+
lastActivityAt: isoOf(lastActivityMs),
|
|
469
|
+
turnSince: isoOf(turn.sinceMs),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function knownConversationIds() {
|
|
474
|
+
return [...sessions.keys()];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
noteTaskSet,
|
|
479
|
+
noteTaskSettled,
|
|
480
|
+
noteSubagentRun,
|
|
481
|
+
noteWorkerDied,
|
|
482
|
+
touch,
|
|
483
|
+
forget,
|
|
484
|
+
describe,
|
|
485
|
+
knownConversationIds,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
@@ -234,6 +234,11 @@ export function createSessionHistoryRefreshService({
|
|
|
234
234
|
// replaces those ids, so the rows must go with the messages they annotated
|
|
235
235
|
// (same lifecycle as subagent_runs). The fallback guards dbs opened before
|
|
236
236
|
// the table existed.
|
|
237
|
+
// The sub-agent timeline goes with its runs. Null on a database the 0009
|
|
238
|
+
// migration has not reached (no table, nothing to delete).
|
|
239
|
+
const deleteConversationSubagentTimeline = typeof stmts?.deleteConvSubagentTimeline?.run === 'function'
|
|
240
|
+
? stmts.deleteConvSubagentTimeline
|
|
241
|
+
: null;
|
|
237
242
|
const deleteConversationWorkflowRuns = typeof stmts?.deleteConvWorkflowRuns?.run === 'function'
|
|
238
243
|
? stmts.deleteConvWorkflowRuns
|
|
239
244
|
: db.prepare(`DELETE FROM workflow_runs WHERE conversation_id = ?`);
|
|
@@ -425,6 +430,7 @@ export function createSessionHistoryRefreshService({
|
|
|
425
430
|
deleteConversationThoughts.run(conversationId);
|
|
426
431
|
deleteConversationStreamEvents.run(conversationId);
|
|
427
432
|
deleteConversationSubagentRuns.run(conversationId);
|
|
433
|
+
deleteConversationSubagentTimeline?.run(conversationId);
|
|
428
434
|
deleteConversationWorkflowRuns.run(conversationId);
|
|
429
435
|
});
|
|
430
436
|
|
|
@@ -441,6 +447,7 @@ export function createSessionHistoryRefreshService({
|
|
|
441
447
|
deleteConversationThoughts.run(conversationId);
|
|
442
448
|
deleteConversationStreamEvents.run(conversationId);
|
|
443
449
|
deleteConversationSubagentRuns.run(conversationId);
|
|
450
|
+
deleteConversationSubagentTimeline?.run(conversationId);
|
|
444
451
|
deleteConversationWorkflowRuns.run(conversationId);
|
|
445
452
|
insertRebuiltMessages(conversationId, messages, attachmentCandidates);
|
|
446
453
|
const rebuiltMessages = ensureArray(messages);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// One STALE ATTEMPT warning per superseded attempt, not one per write. A
|
|
4
|
+
// worker whose turn was requeued under it keeps streaming for the old
|
|
5
|
+
// attempt until it learns of the refusal, several writes a second (live:
|
|
6
|
+
// 696 lines in four minutes after one recovery). The first refusal of an
|
|
7
|
+
// attempt is logged in full; the rest are counted and summarized in one
|
|
8
|
+
// line once the attempt has been quiet for `quietMs`.
|
|
9
|
+
|
|
10
|
+
export const STALE_ATTEMPT_SUMMARY_QUIET_MS = 60_000;
|
|
11
|
+
const MAX_TRACKED_ATTEMPTS = 500;
|
|
12
|
+
|
|
13
|
+
function short(value) {
|
|
14
|
+
return String(value || '').trim().slice(0, 8) || 'none';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `warn(line)` writes one line (without timestamp). `now`, `setTimer` and
|
|
19
|
+
* `clearTimer` are injectable for tests.
|
|
20
|
+
*/
|
|
21
|
+
export function createStaleAttemptLogLimiter({
|
|
22
|
+
warn = (line) => console.warn(line),
|
|
23
|
+
now = () => Date.now(),
|
|
24
|
+
setTimer = (fn, ms) => {
|
|
25
|
+
const timer = setTimeout(fn, ms);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
return timer;
|
|
28
|
+
},
|
|
29
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
30
|
+
quietMs = STALE_ATTEMPT_SUMMARY_QUIET_MS,
|
|
31
|
+
maxTracked = MAX_TRACKED_ATTEMPTS,
|
|
32
|
+
} = {}) {
|
|
33
|
+
// `${messageId}:${attemptId}` → { messageId, attemptId, count, firstAt, lastAt, routes, current, status, timer }
|
|
34
|
+
const entries = new Map();
|
|
35
|
+
|
|
36
|
+
function summarize(key) {
|
|
37
|
+
const entry = entries.get(key);
|
|
38
|
+
if (!entry) return;
|
|
39
|
+
entries.delete(key);
|
|
40
|
+
if (entry.timer) clearTimer(entry.timer);
|
|
41
|
+
const more = entry.count - 1;
|
|
42
|
+
if (more <= 0) return;
|
|
43
|
+
const seconds = Math.max(1, Math.round((entry.lastAt - entry.firstAt) / 1000));
|
|
44
|
+
warn(`STALE ATTEMPT ${short(entry.messageId)} attempt=${short(entry.attemptId)}: ${more} more write${more === 1 ? '' : 's'} refused over ${seconds}s (routes ${[...entry.routes].join(',')}; current=${short(entry.current)} status=${entry.status || 'unknown'})`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function arm(key, entry) {
|
|
48
|
+
if (entry.timer) clearTimer(entry.timer);
|
|
49
|
+
entry.timer = setTimer(() => {
|
|
50
|
+
entry.timer = null;
|
|
51
|
+
summarize(key);
|
|
52
|
+
}, quietMs);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A write of `attemptId` for `messageId` was refused because the row's
|
|
57
|
+
* attempt is `currentAttemptId`. Returns `{ logged, count }`.
|
|
58
|
+
*/
|
|
59
|
+
function note({ messageId, attemptId, currentAttemptId = '', route = 'unknown', status = '' } = {}) {
|
|
60
|
+
const key = `${String(messageId || '')}:${String(attemptId || '')}`;
|
|
61
|
+
const at = now();
|
|
62
|
+
const existing = entries.get(key);
|
|
63
|
+
if (existing) {
|
|
64
|
+
existing.count += 1;
|
|
65
|
+
existing.lastAt = at;
|
|
66
|
+
existing.routes.add(String(route || 'unknown'));
|
|
67
|
+
existing.current = currentAttemptId;
|
|
68
|
+
existing.status = String(status || existing.status || '');
|
|
69
|
+
arm(key, existing);
|
|
70
|
+
return { logged: false, count: existing.count };
|
|
71
|
+
}
|
|
72
|
+
if (entries.size >= maxTracked) summarize(entries.keys().next().value);
|
|
73
|
+
const entry = {
|
|
74
|
+
messageId,
|
|
75
|
+
attemptId,
|
|
76
|
+
count: 1,
|
|
77
|
+
firstAt: at,
|
|
78
|
+
lastAt: at,
|
|
79
|
+
routes: new Set([String(route || 'unknown')]),
|
|
80
|
+
current: currentAttemptId,
|
|
81
|
+
status: String(status || ''),
|
|
82
|
+
timer: null,
|
|
83
|
+
};
|
|
84
|
+
entries.set(key, entry);
|
|
85
|
+
arm(key, entry);
|
|
86
|
+
warn(`STALE ATTEMPT ${short(messageId)} route=${route} claimed=${short(attemptId)} current=${short(currentAttemptId)} status=${status || 'unknown'} (further writes of this attempt are counted and summarized once it goes quiet)`);
|
|
87
|
+
return { logged: true, count: 1 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Writes every pending summary now (shutdown, tests). */
|
|
91
|
+
function flush() {
|
|
92
|
+
for (const key of [...entries.keys()]) summarize(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { note, flush, size: () => entries.size };
|
|
96
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Stale recovery after a wall-clock jump. The stale window is measured from
|
|
4
|
+
// the worker's last heartbeat: after the host slept for hours, every
|
|
5
|
+
// heartbeat is hours old the moment the relay's timer fires again, and the
|
|
6
|
+
// recovery requeued live turns (live: three messages recovered after a 6 h
|
|
7
|
+
// sleep, delivered a second time, and the live worker's replies refused as
|
|
8
|
+
// stale attempts, 696 warnings in 4 minutes).
|
|
9
|
+
//
|
|
10
|
+
// The guard notices the jump itself: the gap between two ticks of the
|
|
11
|
+
// recovery timer far larger than its interval (a sleep, a stopped VM, a
|
|
12
|
+
// blocked event loop, which stalls heartbeats just the same). After one, no
|
|
13
|
+
// recovery runs for a grace period, long enough for a live worker to
|
|
14
|
+
// heartbeat again; a dead worker's rows are recovered after it.
|
|
15
|
+
|
|
16
|
+
export const CLOCK_JUMP_FACTOR = 4;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `intervalMs` is the recovery timer's interval; a gap above
|
|
20
|
+
* `jumpThresholdMs` (default: four intervals) is a jump; `graceMs` is how
|
|
21
|
+
* long recovery then waits. `now()` is injectable for tests.
|
|
22
|
+
*/
|
|
23
|
+
export function createStaleRecoveryClockGuard({
|
|
24
|
+
now = () => Date.now(),
|
|
25
|
+
intervalMs,
|
|
26
|
+
jumpThresholdMs = null,
|
|
27
|
+
graceMs,
|
|
28
|
+
log = () => {},
|
|
29
|
+
} = {}) {
|
|
30
|
+
const interval = Math.max(1, Number(intervalMs) || 1);
|
|
31
|
+
const threshold = Number(jumpThresholdMs) > 0 ? Number(jumpThresholdMs) : interval * CLOCK_JUMP_FACTOR;
|
|
32
|
+
const grace = Math.max(0, Number(graceMs) || 0);
|
|
33
|
+
let lastTickMs = null;
|
|
34
|
+
let graceUntilMs = 0;
|
|
35
|
+
let inGrace = false;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One tick of the recovery timer. `{ skip, jumped, gapMs,
|
|
39
|
+
* graceRemainingMs }`: `skip` says recovery must not run now.
|
|
40
|
+
*/
|
|
41
|
+
function tick() {
|
|
42
|
+
const at = now();
|
|
43
|
+
const gapMs = lastTickMs === null ? 0 : Math.max(0, at - lastTickMs);
|
|
44
|
+
lastTickMs = at;
|
|
45
|
+
const jumped = gapMs > threshold;
|
|
46
|
+
if (jumped) {
|
|
47
|
+
graceUntilMs = Math.max(graceUntilMs, at + grace);
|
|
48
|
+
log(`Clock jump: ${Math.round(gapMs / 1000)}s passed between stale-recovery ticks (interval ${Math.round(interval / 1000)}s); stale recovery paused for ${Math.round(grace / 1000)}s so live workers can report`);
|
|
49
|
+
}
|
|
50
|
+
const skip = at < graceUntilMs;
|
|
51
|
+
if (inGrace && !skip) log('Stale recovery resumed after the clock jump');
|
|
52
|
+
inGrace = skip;
|
|
53
|
+
return { skip, jumped, gapMs, graceRemainingMs: Math.max(0, graceUntilMs - at) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { tick, thresholdMs: threshold, graceMs: grace };
|
|
57
|
+
}
|