@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. 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
+ }