@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
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reading a background task's output for the console: the file the worker
|
|
5
|
+
* names as the task's `outputPath` (the Claude CLI's task file, a Copilot
|
|
6
|
+
* shell log, the worker's own spool), read by the relay itself.
|
|
7
|
+
*
|
|
8
|
+
* - readTail / readBefore / readRange: byte windows of the file, cut on line
|
|
9
|
+
* and character boundaries, returned cleaned (control codes out, colours
|
|
10
|
+
* kept, credentials scrubbed) with the processed line model.
|
|
11
|
+
* - the path guard: an absolute path of at most 1,024 characters, a regular
|
|
12
|
+
* file that is not a symlink (opened with O_NOFOLLOW where the platform has
|
|
13
|
+
* it), reads capped per request.
|
|
14
|
+
* - snapshots: the 8 KiB a worker posts for a task with no file (Claude's
|
|
15
|
+
* getTaskOutput), cached per task.
|
|
16
|
+
* - the health poller: every 10 s, for live tasks with an output file (and
|
|
17
|
+
* ended ones, read once more after the end), the time of the last output
|
|
18
|
+
* and the test runner's counts, sent to the pages as
|
|
19
|
+
* `background_tasks_health` when they change. A runner's closing summary
|
|
20
|
+
* comes from the last 64 KB; the counts of a run still going come from the
|
|
21
|
+
* whole file, read on per task as it grows (the runner scan).
|
|
22
|
+
*
|
|
23
|
+
* The worker is trusted (it runs on this host as this user); the guard stops
|
|
24
|
+
* accidents, not an attacker.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
TASK_OUTPUT_LIMITS,
|
|
31
|
+
cleanTaskOutput,
|
|
32
|
+
createRunnerTally,
|
|
33
|
+
foldTaskOutput,
|
|
34
|
+
normalizeTaskOutputKind,
|
|
35
|
+
normalizeTaskOutputPath,
|
|
36
|
+
parseRunnerSummary,
|
|
37
|
+
stripControlKeepSgr,
|
|
38
|
+
} from '../../shared/task-output-text.mjs';
|
|
39
|
+
|
|
40
|
+
const NEWLINE = 0x0a;
|
|
41
|
+
|
|
42
|
+
function clampInt(value, { min, max, fallback }) {
|
|
43
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
44
|
+
const number = Number(value);
|
|
45
|
+
if (!Number.isFinite(number)) return fallback;
|
|
46
|
+
return Math.min(max, Math.max(min, Math.trunc(number)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The file kinds the relay reads itself. */
|
|
50
|
+
export function isFileOutputKind(kind) {
|
|
51
|
+
return kind === 'file' || kind === 'spool';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A task row's output as `{ kind, path }` (`path` '' when there is no readable one). */
|
|
55
|
+
export function taskOutputSource(task) {
|
|
56
|
+
const path = normalizeTaskOutputPath(task?.outputPath);
|
|
57
|
+
const kind = normalizeTaskOutputKind(task?.outputKind, path);
|
|
58
|
+
return { kind, path: isFileOutputKind(kind) ? path : '' };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What the file at `filePath` is: `{ ok: true, size, mtimeMs }`, or
|
|
63
|
+
* `{ ok: false, code }` with code `invalid-path`, `missing`, `symlink`,
|
|
64
|
+
* `not-file` or `unreadable`.
|
|
65
|
+
*/
|
|
66
|
+
export async function inspectOutputFile(filePath, { fsImpl = fs } = {}) {
|
|
67
|
+
const path = normalizeTaskOutputPath(filePath);
|
|
68
|
+
if (!path) return { ok: false, code: 'invalid-path' };
|
|
69
|
+
let stats;
|
|
70
|
+
try {
|
|
71
|
+
stats = await fsImpl.promises.lstat(path);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : 'unreadable' };
|
|
74
|
+
}
|
|
75
|
+
if (stats.isSymbolicLink()) return { ok: false, code: 'symlink' };
|
|
76
|
+
if (!stats.isFile()) return { ok: false, code: 'not-file' };
|
|
77
|
+
return { ok: true, size: stats.size, mtimeMs: stats.mtimeMs, ino: stats.ino };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Reads [start, end) of the file. Opened without following a symlink where
|
|
81
|
+
// the platform can, and checked again on the open handle: a path swapped
|
|
82
|
+
// between the lstat and the open is refused.
|
|
83
|
+
async function readBytes(path, start, end, { fsImpl = fs } = {}) {
|
|
84
|
+
// O_NONBLOCK: a FIFO swapped in after the lstat must not hold a pool
|
|
85
|
+
// thread until a writer appears (no effect on a regular file).
|
|
86
|
+
const flags = fsImpl.constants.O_RDONLY | (fsImpl.constants.O_NOFOLLOW || 0) | (fsImpl.constants.O_NONBLOCK || 0);
|
|
87
|
+
let handle;
|
|
88
|
+
try {
|
|
89
|
+
handle = await fsImpl.promises.open(path, flags);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return { ok: false, code: error?.code === 'ENOENT' ? 'missing' : (error?.code === 'ELOOP' ? 'symlink' : 'unreadable') };
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const stats = await handle.stat();
|
|
95
|
+
if (!stats.isFile()) return { ok: false, code: 'not-file' };
|
|
96
|
+
const size = stats.size;
|
|
97
|
+
const from = Math.max(0, Math.min(start, size));
|
|
98
|
+
const to = Math.max(from, Math.min(end, size));
|
|
99
|
+
const buffer = Buffer.alloc(to - from);
|
|
100
|
+
let read = 0;
|
|
101
|
+
while (read < buffer.length) {
|
|
102
|
+
const { bytesRead } = await handle.read(buffer, read, buffer.length - read, from + read);
|
|
103
|
+
if (!bytesRead) break;
|
|
104
|
+
read += bytesRead;
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, buffer: buffer.subarray(0, read), start: from, size, mtimeMs: stats.mtimeMs };
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, code: 'unreadable' };
|
|
109
|
+
} finally {
|
|
110
|
+
try { await handle.close(); } catch {}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How many bytes of `buffer` can be decoded and cleaned now without cutting
|
|
116
|
+
* a UTF-8 character or an escape sequence in two: the rest waits for the
|
|
117
|
+
* next read (the writer is mid-write).
|
|
118
|
+
*/
|
|
119
|
+
export function safeChunkLength(buffer) {
|
|
120
|
+
let end = buffer.length;
|
|
121
|
+
// An incomplete UTF-8 sequence at the end.
|
|
122
|
+
let lead = end - 1;
|
|
123
|
+
let continuation = 0;
|
|
124
|
+
while (lead >= 0 && continuation < 3 && (buffer[lead] & 0xc0) === 0x80) {
|
|
125
|
+
lead -= 1;
|
|
126
|
+
continuation += 1;
|
|
127
|
+
}
|
|
128
|
+
if (lead >= 0) {
|
|
129
|
+
const byte = buffer[lead];
|
|
130
|
+
const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
|
|
131
|
+
if (width > 1 && continuation + 1 < width) end = lead;
|
|
132
|
+
}
|
|
133
|
+
// An escape sequence still open at the end (ESC, ESC [ 3 1, ESC ] title…).
|
|
134
|
+
const tailStart = Math.max(0, end - 256);
|
|
135
|
+
const tail = buffer.subarray(tailStart, end).toString('latin1');
|
|
136
|
+
const esc = tail.lastIndexOf('\x1b');
|
|
137
|
+
if (esc !== -1) {
|
|
138
|
+
const rest = tail.slice(esc);
|
|
139
|
+
const complete = /^\x1b\[[0-?]*[ -/]*[@-~]/.test(rest)
|
|
140
|
+
|| /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.test(rest)
|
|
141
|
+
|| /^\x1b[^[\]]/.test(rest);
|
|
142
|
+
// An OSC that never ends within 256 bytes is garbage; let it through.
|
|
143
|
+
const hopelessOsc = rest.startsWith('\x1b]') && rest.length >= 256;
|
|
144
|
+
if (!complete && !hopelessOsc) end = tailStart + esc;
|
|
145
|
+
}
|
|
146
|
+
return end;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The byte offset just after the first newline at or after `from`, or -1.
|
|
150
|
+
function afterNewline(buffer, from = 0) {
|
|
151
|
+
const at = buffer.indexOf(NEWLINE, from);
|
|
152
|
+
return at === -1 ? -1 : at + 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The start of the last `count` lines of `buffer` (a line ends with "\n";
|
|
156
|
+
// an unterminated last line counts as one).
|
|
157
|
+
function startOfLastLines(buffer, count) {
|
|
158
|
+
let lines = 0;
|
|
159
|
+
let position = buffer.length;
|
|
160
|
+
if (position > 0 && buffer[position - 1] === NEWLINE) position -= 1;
|
|
161
|
+
while (position > 0) {
|
|
162
|
+
const at = buffer.lastIndexOf(NEWLINE, position - 1);
|
|
163
|
+
lines += 1;
|
|
164
|
+
if (lines >= count || at === -1) return lines >= count ? at + 1 : 0;
|
|
165
|
+
position = at;
|
|
166
|
+
}
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function skipContinuationBytes(buffer, from) {
|
|
171
|
+
let start = from;
|
|
172
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1;
|
|
173
|
+
return start;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function shapeWindow(buffer, windowStart, size, mtimeMs) {
|
|
177
|
+
const text = cleanTaskOutput(buffer.toString('utf8'));
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
text,
|
|
181
|
+
startOffset: windowStart,
|
|
182
|
+
endOffset: windowStart + buffer.length,
|
|
183
|
+
size,
|
|
184
|
+
mtime: mtimeMs,
|
|
185
|
+
hasMoreBefore: windowStart > 0,
|
|
186
|
+
lines: foldTaskOutput(text),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The last `lines` lines of the bytes before `end`, within `maxBytes`.
|
|
191
|
+
async function readLinesEndingAt(filePath, end, { lines, maxBytes, trimEnd, fsImpl }) {
|
|
192
|
+
const windowStart = Math.max(0, end - maxBytes);
|
|
193
|
+
const read = await readBytes(filePath, windowStart, end, { fsImpl });
|
|
194
|
+
if (!read.ok) return read;
|
|
195
|
+
let buffer = read.buffer;
|
|
196
|
+
let start = 0;
|
|
197
|
+
// A window that starts mid-file starts on the next line; one long line
|
|
198
|
+
// with no newline at all starts on the next character.
|
|
199
|
+
if (read.start > 0) {
|
|
200
|
+
const next = afterNewline(buffer);
|
|
201
|
+
start = next === -1 || next >= buffer.length ? skipContinuationBytes(buffer, 0) : next;
|
|
202
|
+
}
|
|
203
|
+
buffer = buffer.subarray(start);
|
|
204
|
+
const fromLines = startOfLastLines(buffer, lines);
|
|
205
|
+
buffer = buffer.subarray(fromLines);
|
|
206
|
+
const firstByte = read.start + start + fromLines;
|
|
207
|
+
if (trimEnd) buffer = buffer.subarray(0, safeChunkLength(buffer));
|
|
208
|
+
return shapeWindow(buffer, firstByte, read.size, read.mtimeMs);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The end of a task's output: the last `lines` lines (default 2,000, at most
|
|
213
|
+
* 5,000) within `maxBytes` (at most 256 KB). Returns `{ ok, text,
|
|
214
|
+
* startOffset, endOffset, size, mtime, hasMoreBefore, lines }`: `text` the
|
|
215
|
+
* cleaned slice (for a viewer's own folder), `lines` the processed line
|
|
216
|
+
* model, offsets in bytes of the file. The end stops short of a character or
|
|
217
|
+
* escape the writer has not finished; `endOffset` is where the live chunks
|
|
218
|
+
* continue. `{ ok: false, code }` when the file cannot be read.
|
|
219
|
+
*/
|
|
220
|
+
export async function readTail(filePath, { lines, maxBytes, fsImpl = fs } = {}) {
|
|
221
|
+
const info = await inspectOutputFile(filePath, { fsImpl });
|
|
222
|
+
if (!info.ok) return info;
|
|
223
|
+
return readLinesEndingAt(normalizeTaskOutputPath(filePath), info.size, {
|
|
224
|
+
lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
|
|
225
|
+
maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
|
|
226
|
+
trimEnd: true,
|
|
227
|
+
fsImpl,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The `lines` lines that end at byte offset `before` (a `startOffset` an earlier read returned). */
|
|
232
|
+
export async function readBefore(filePath, { before, lines, maxBytes, fsImpl = fs } = {}) {
|
|
233
|
+
const info = await inspectOutputFile(filePath, { fsImpl });
|
|
234
|
+
if (!info.ok) return info;
|
|
235
|
+
const end = clampInt(before, { min: 0, max: info.size, fallback: info.size });
|
|
236
|
+
return readLinesEndingAt(normalizeTaskOutputPath(filePath), end, {
|
|
237
|
+
lines: clampInt(lines, { min: 1, max: TASK_OUTPUT_LIMITS.tailLinesMax, fallback: TASK_OUTPUT_LIMITS.tailLinesDefault }),
|
|
238
|
+
maxBytes: clampInt(maxBytes, { min: 1, max: TASK_OUTPUT_LIMITS.requestBytesMax, fallback: TASK_OUTPUT_LIMITS.requestBytesMax }),
|
|
239
|
+
trimEnd: false,
|
|
240
|
+
fsImpl,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The bytes from `start` on, at most `maxBytes` (64 KB), for a live chunk:
|
|
246
|
+
* `{ ok, offset, endOffset, text, size, mtime }` with `text` cleaned and the
|
|
247
|
+
* end on a safe boundary. `endOffset === offset` when nothing complete is
|
|
248
|
+
* there yet.
|
|
249
|
+
*/
|
|
250
|
+
export async function readRange(filePath, {
|
|
251
|
+
start,
|
|
252
|
+
maxBytes = TASK_OUTPUT_LIMITS.chunkBytes,
|
|
253
|
+
// Stop after the last line break (`\n` or `\r`): credentials are scrubbed
|
|
254
|
+
// per piece, so a piece must not end inside one. An open line goes out
|
|
255
|
+
// only when it is `longLineBytes` long, or with a later call without this.
|
|
256
|
+
holdOpenLine = false,
|
|
257
|
+
longLineBytes = TASK_OUTPUT_LIMITS.chunkBytes,
|
|
258
|
+
fsImpl = fs,
|
|
259
|
+
} = {}) {
|
|
260
|
+
const path = normalizeTaskOutputPath(filePath);
|
|
261
|
+
if (!path) return { ok: false, code: 'invalid-path' };
|
|
262
|
+
const from = Math.max(0, Math.trunc(Number(start) || 0));
|
|
263
|
+
const read = await readBytes(path, from, from + Math.max(1, maxBytes), { fsImpl });
|
|
264
|
+
if (!read.ok) return read;
|
|
265
|
+
let length = safeChunkLength(read.buffer);
|
|
266
|
+
if (holdOpenLine && length > 0) {
|
|
267
|
+
let cut = -1;
|
|
268
|
+
for (let index = length - 1; index >= 0; index -= 1) {
|
|
269
|
+
const byte = read.buffer[index];
|
|
270
|
+
if (byte === NEWLINE || byte === 0x0d) {
|
|
271
|
+
cut = index + 1;
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (cut > 0) length = cut;
|
|
276
|
+
else if (length < longLineBytes) length = 0;
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
offset: read.start,
|
|
281
|
+
endOffset: read.start + length,
|
|
282
|
+
text: cleanTaskOutput(read.buffer.subarray(0, length).toString('utf8')),
|
|
283
|
+
size: read.size,
|
|
284
|
+
mtime: read.mtimeMs,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The output a worker posted for a task with no file (`output-snapshot`),
|
|
292
|
+
* per store key and task; kept an hour like the ended rows.
|
|
293
|
+
*/
|
|
294
|
+
export function createTaskOutputSnapshots({ now = () => Date.now(), keepMs = TASK_OUTPUT_LIMITS.endedKeepMs, max = 200 } = {}) {
|
|
295
|
+
const snapshots = new Map();
|
|
296
|
+
const keyOf = (key, taskId) => `${String(key || '').trim()}\u0000${String(taskId || '').trim()}`;
|
|
297
|
+
|
|
298
|
+
function prune() {
|
|
299
|
+
const cutoff = now() - keepMs;
|
|
300
|
+
for (const [key, entry] of snapshots) if (entry.at < cutoff) snapshots.delete(key);
|
|
301
|
+
while (snapshots.size > max) snapshots.delete(snapshots.keys().next().value);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
set(key, taskId, { output, totalBytes, truncated } = {}) {
|
|
306
|
+
const text = String(output ?? '').slice(0, 64 * 1024);
|
|
307
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
308
|
+
const total = Number.isInteger(Number(totalBytes)) && Number(totalBytes) >= bytes ? Number(totalBytes) : bytes;
|
|
309
|
+
const id = keyOf(key, taskId);
|
|
310
|
+
snapshots.delete(id);
|
|
311
|
+
snapshots.set(id, { output: text, totalBytes: total, truncated: truncated === true || total > bytes, at: now() });
|
|
312
|
+
prune();
|
|
313
|
+
},
|
|
314
|
+
get(...args) {
|
|
315
|
+
prune();
|
|
316
|
+
const [keys, taskId] = args;
|
|
317
|
+
for (const key of Array.isArray(keys) ? keys : [keys]) {
|
|
318
|
+
const entry = snapshots.get(keyOf(key, taskId));
|
|
319
|
+
if (entry) return entry;
|
|
320
|
+
}
|
|
321
|
+
return null;
|
|
322
|
+
},
|
|
323
|
+
forget(key, taskId) {
|
|
324
|
+
snapshots.delete(keyOf(key, taskId));
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A snapshot as the output route answers it. */
|
|
330
|
+
export function shapeSnapshot(snapshot) {
|
|
331
|
+
const text = cleanTaskOutput(snapshot.output);
|
|
332
|
+
const bytes = Buffer.byteLength(snapshot.output, 'utf8');
|
|
333
|
+
return {
|
|
334
|
+
text,
|
|
335
|
+
startOffset: Math.max(0, snapshot.totalBytes - bytes),
|
|
336
|
+
endOffset: snapshot.totalBytes,
|
|
337
|
+
size: snapshot.totalBytes,
|
|
338
|
+
mtime: snapshot.at,
|
|
339
|
+
hasMoreBefore: false,
|
|
340
|
+
truncated: !!snapshot.truncated,
|
|
341
|
+
lines: foldTaskOutput(text),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Health ──────────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
// A log line as the console shows it: a `\r` redraw keeps its last state,
|
|
348
|
+
// control codes go (colours stay, the runner parser ignores them).
|
|
349
|
+
function healthLineText(raw) {
|
|
350
|
+
const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
351
|
+
return stripControlKeepSgr(body.slice(body.lastIndexOf('\r') + 1));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Runner counts of the window `buffer` that starts at byte `windowStart` of
|
|
356
|
+
* the file: `{ counts, runner, final, firstFailureOffset }`, the offset the
|
|
357
|
+
* byte where the first failure's line starts (null when there is none in the
|
|
358
|
+
* window).
|
|
359
|
+
*/
|
|
360
|
+
export function healthOfWindow(buffer, windowStart) {
|
|
361
|
+
let start = 0;
|
|
362
|
+
if (windowStart > 0) {
|
|
363
|
+
const next = afterNewline(buffer);
|
|
364
|
+
start = next === -1 ? buffer.length : next;
|
|
365
|
+
}
|
|
366
|
+
const texts = [];
|
|
367
|
+
const offsets = [];
|
|
368
|
+
let position = start;
|
|
369
|
+
while (position < buffer.length) {
|
|
370
|
+
const at = buffer.indexOf(NEWLINE, position);
|
|
371
|
+
const end = at === -1 ? buffer.length : at;
|
|
372
|
+
texts.push(healthLineText(buffer.subarray(position, end).toString('utf8')));
|
|
373
|
+
offsets.push(windowStart + position);
|
|
374
|
+
position = end + 1;
|
|
375
|
+
}
|
|
376
|
+
const summary = parseRunnerSummary(texts);
|
|
377
|
+
if (!summary) return { counts: null, runner: null, final: false, firstFailureOffset: null };
|
|
378
|
+
return {
|
|
379
|
+
counts: { passed: summary.passed, failed: summary.failed, skipped: summary.skipped, ...(summary.flaky ? { flaky: summary.flaky } : {}) },
|
|
380
|
+
runner: summary.runner,
|
|
381
|
+
final: summary.final,
|
|
382
|
+
firstFailureOffset: Number.isInteger(summary.firstFailureLine) ? offsets[summary.firstFailureLine] : null,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ─── The runner scan ─────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
// A line longer than this is no test runner's; it is skipped, not held.
|
|
389
|
+
const SCAN_LINE_BYTES_MAX = 64 * 1024;
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The runner counts of a whole output file, read on from where the last read
|
|
393
|
+
* stopped as the file grows: a live run whose output passed 64 KB keeps its
|
|
394
|
+
* runner (named in its first lines) and every test counted. One per live
|
|
395
|
+
* task whose output outgrew the window, in the health poller's memory
|
|
396
|
+
* (lines before a runner's header are only looked at): after a
|
|
397
|
+
* relay restart it is built again from the start of the file, within the
|
|
398
|
+
* poller's byte budget per tick, so a multi-MB log costs a few ticks, not
|
|
399
|
+
* one long one.
|
|
400
|
+
*/
|
|
401
|
+
export function createRunnerScan(filePath) {
|
|
402
|
+
return {
|
|
403
|
+
path: filePath,
|
|
404
|
+
offset: 0,
|
|
405
|
+
size: 0,
|
|
406
|
+
ino: null,
|
|
407
|
+
mtimeMs: null,
|
|
408
|
+
lastReadBytes: 0,
|
|
409
|
+
// It has read the whole file once: behind by a tick's new bytes, its
|
|
410
|
+
// counts are still the run's (a few tests short), not a partial rebuild.
|
|
411
|
+
caughtUpOnce: false,
|
|
412
|
+
carry: null,
|
|
413
|
+
carryStart: 0,
|
|
414
|
+
skipping: false,
|
|
415
|
+
tally: createRunnerTally(),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Whether the scan has read all of the file as its last read found it. */
|
|
420
|
+
export function runnerScanCaughtUp(scan) {
|
|
421
|
+
return !!scan && scan.offset >= scan.size;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function holdOpenLine(scan, piece, pieceStart) {
|
|
425
|
+
if (scan.skipping || !piece.length) return;
|
|
426
|
+
if (!scan.carry) {
|
|
427
|
+
scan.carry = Buffer.from(piece);
|
|
428
|
+
scan.carryStart = pieceStart;
|
|
429
|
+
} else {
|
|
430
|
+
scan.carry = Buffer.concat([scan.carry, piece]);
|
|
431
|
+
}
|
|
432
|
+
if (scan.carry.length > SCAN_LINE_BYTES_MAX) {
|
|
433
|
+
scan.carry = null;
|
|
434
|
+
scan.skipping = true;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function scanLines(scan, buffer, bufferStart) {
|
|
439
|
+
let position = 0;
|
|
440
|
+
while (position < buffer.length) {
|
|
441
|
+
const at = buffer.indexOf(NEWLINE, position);
|
|
442
|
+
if (at === -1) {
|
|
443
|
+
holdOpenLine(scan, buffer.subarray(position), bufferStart + position);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
let line = buffer.subarray(position, at);
|
|
447
|
+
let lineStart = bufferStart + position;
|
|
448
|
+
if (scan.carry) {
|
|
449
|
+
line = Buffer.concat([scan.carry, line]);
|
|
450
|
+
lineStart = scan.carryStart;
|
|
451
|
+
scan.carry = null;
|
|
452
|
+
}
|
|
453
|
+
if (scan.skipping) scan.skipping = false;
|
|
454
|
+
else if (line.length <= SCAN_LINE_BYTES_MAX) scan.tally.push(healthLineText(line.toString('utf8')), lineStart);
|
|
455
|
+
position = at + 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Reads the file on to `size` (its current size), at most `budgetBytes` in
|
|
461
|
+
* pieces of `pieceBytes`; `scan.lastReadBytes` says how many it read. A new
|
|
462
|
+
* file under the path (shorter than what was read, another inode, an older
|
|
463
|
+
* mtime) starts the scan over. Returns whether it caught up.
|
|
464
|
+
*/
|
|
465
|
+
export async function advanceRunnerScan(scan, size, {
|
|
466
|
+
budgetBytes = 4 * 1024 * 1024,
|
|
467
|
+
pieceBytes = 1024 * 1024,
|
|
468
|
+
ino = null,
|
|
469
|
+
mtimeMs = null,
|
|
470
|
+
fsImpl = fs,
|
|
471
|
+
} = {}) {
|
|
472
|
+
const replaced = size < scan.offset
|
|
473
|
+
|| (ino !== null && scan.ino !== null && ino !== scan.ino)
|
|
474
|
+
|| (Number.isFinite(mtimeMs) && Number.isFinite(scan.mtimeMs) && mtimeMs < scan.mtimeMs);
|
|
475
|
+
if (replaced) Object.assign(scan, createRunnerScan(scan.path));
|
|
476
|
+
scan.size = size;
|
|
477
|
+
if (ino !== null) scan.ino = ino;
|
|
478
|
+
if (Number.isFinite(mtimeMs)) scan.mtimeMs = mtimeMs;
|
|
479
|
+
scan.lastReadBytes = 0;
|
|
480
|
+
let budget = Math.max(0, budgetBytes);
|
|
481
|
+
while (scan.offset < size && budget > 0) {
|
|
482
|
+
const want = Math.min(pieceBytes, budget, size - scan.offset);
|
|
483
|
+
const read = await readBytes(scan.path, scan.offset, scan.offset + want, { fsImpl });
|
|
484
|
+
if (!read.ok || !read.buffer.length) break;
|
|
485
|
+
scanLines(scan, read.buffer, read.start);
|
|
486
|
+
scan.offset = read.start + read.buffer.length;
|
|
487
|
+
budget -= read.buffer.length;
|
|
488
|
+
scan.lastReadBytes += read.buffer.length;
|
|
489
|
+
}
|
|
490
|
+
if (runnerScanCaughtUp(scan)) scan.caughtUpOnce = true;
|
|
491
|
+
return runnerScanCaughtUp(scan);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The window's reading with what the whole file says: a run still going is
|
|
496
|
+
* counted from the scan when the window does not hold the whole file (its
|
|
497
|
+
* header may be gone, its early tests are), and a final summary whose first
|
|
498
|
+
* failure lies before the window gets the scan's.
|
|
499
|
+
*/
|
|
500
|
+
export function healthWithScan(windowHealth, windowStart, whole) {
|
|
501
|
+
if (!whole) return windowHealth;
|
|
502
|
+
if (windowHealth.final) {
|
|
503
|
+
if (windowHealth.firstFailureOffset === null && windowHealth.counts?.failed > 0 && whole.firstFailureLine !== null) {
|
|
504
|
+
return { ...windowHealth, firstFailureOffset: whole.firstFailureLine };
|
|
505
|
+
}
|
|
506
|
+
return windowHealth;
|
|
507
|
+
}
|
|
508
|
+
if (windowStart === 0) return windowHealth;
|
|
509
|
+
return {
|
|
510
|
+
counts: { passed: whole.passed, failed: whole.failed, skipped: whole.skipped },
|
|
511
|
+
runner: whole.runner,
|
|
512
|
+
final: false,
|
|
513
|
+
firstFailureOffset: whole.firstFailureLine,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// The list starting at `turn` modulo its length.
|
|
518
|
+
function rotate(list, turn) {
|
|
519
|
+
if (list.length < 2) return list;
|
|
520
|
+
const at = turn % list.length;
|
|
521
|
+
return [...list.slice(at), ...list.slice(0, at)];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function sameHealthFacts(a, b) {
|
|
525
|
+
if (!a || !b) return false;
|
|
526
|
+
return a.runner === b.runner
|
|
527
|
+
&& a.final === b.final
|
|
528
|
+
&& a.firstFailureOffset === b.firstFailureOffset
|
|
529
|
+
&& JSON.stringify(a.counts) === JSON.stringify(b.counts);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Every `intervalMs` (10 s) over the live tasks with an output file: `stat`,
|
|
534
|
+
* and when the size or time changed, the last 64 KB parsed for runner
|
|
535
|
+
* counts. A live task whose output outgrew that window gets a runner scan,
|
|
536
|
+
* read on as the file grows (until it has caught up even when the file did
|
|
537
|
+
* not change): it looks for a runner's header and counts from there. All
|
|
538
|
+
* scans of a tick share `scanBytesPerTick`, each tick starting at another
|
|
539
|
+
* task; ended rows never start one, and only read on one they had. Readings go
|
|
540
|
+
* into the store (`setHealth`); a key's readings go to the pages as
|
|
541
|
+
* `background_tasks_health { conversationId, health }` when the counts
|
|
542
|
+
* change, and when the last output moved on by a minute or more (the stall
|
|
543
|
+
* hint counts minutes). `conversationId` is the store key, as in
|
|
544
|
+
* `background_tasks`.
|
|
545
|
+
*
|
|
546
|
+
* Ended rows are read too, until a reading finds the file as the last one
|
|
547
|
+
* did: a task that ends between two ticks has its last output (a runner's
|
|
548
|
+
* summary) read after the end, onto the ended row and to the pages. `poke`
|
|
549
|
+
* runs a tick soon, for the moment a task ends.
|
|
550
|
+
*/
|
|
551
|
+
export function createTaskHealthPoller({
|
|
552
|
+
store,
|
|
553
|
+
io,
|
|
554
|
+
intervalMs = 10_000,
|
|
555
|
+
windowBytes = TASK_OUTPUT_LIMITS.healthBytes,
|
|
556
|
+
scanBytesPerTick = 4 * 1024 * 1024,
|
|
557
|
+
fsImpl = fs,
|
|
558
|
+
logger = console,
|
|
559
|
+
setIntervalImpl = setInterval,
|
|
560
|
+
clearIntervalImpl = clearInterval,
|
|
561
|
+
setTimeoutImpl = setTimeout,
|
|
562
|
+
pokeDelayMs = 250,
|
|
563
|
+
} = {}) {
|
|
564
|
+
let timer = null;
|
|
565
|
+
let running = false;
|
|
566
|
+
let again = false;
|
|
567
|
+
let poked = null;
|
|
568
|
+
// key → taskId → the reading last sent to the pages.
|
|
569
|
+
const sent = new Map();
|
|
570
|
+
// key → taskId → the `endedAt` of the ended row whose file was read after
|
|
571
|
+
// the end and found unchanged: nothing more to read for it.
|
|
572
|
+
const settledEnded = new Map();
|
|
573
|
+
// key \u0000 taskId → the task's runner scan; those no tick reads go.
|
|
574
|
+
const scans = new Map();
|
|
575
|
+
let scansRead = new Set();
|
|
576
|
+
// What the scans of this tick may still read, and the tick's number: each
|
|
577
|
+
// tick starts its round at another task, so one busy log cannot take the
|
|
578
|
+
// budget from the others every time.
|
|
579
|
+
let budgetLeft = scanBytesPerTick;
|
|
580
|
+
let tickNumber = 0;
|
|
581
|
+
|
|
582
|
+
// The task's scan, if it has one (a scan of another path goes).
|
|
583
|
+
function existingScan(id, path) {
|
|
584
|
+
scansRead.add(id);
|
|
585
|
+
const scan = scans.get(id);
|
|
586
|
+
if (scan && scan.path !== path) {
|
|
587
|
+
scans.delete(id);
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return scan || null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function readHealth(key, task, previous, { ended = false } = {}) {
|
|
594
|
+
const { path } = taskOutputSource(task);
|
|
595
|
+
if (!path) return null;
|
|
596
|
+
const id = `${key}\u0000${String(task?.taskId || '').trim()}`;
|
|
597
|
+
let scan = existingScan(id, path);
|
|
598
|
+
const info = await inspectOutputFile(path, { fsImpl });
|
|
599
|
+
// A file not there yet (a spool is named before its first byte): no
|
|
600
|
+
// output yet, quiet since the task started.
|
|
601
|
+
if (!info.ok && info.code === 'missing' && !previous?.size) {
|
|
602
|
+
const startedAt = Number(task?.startedAt) || null;
|
|
603
|
+
return { lastOutputAt: startedAt, size: 0, counts: null, runner: null, final: false, firstFailureOffset: null };
|
|
604
|
+
}
|
|
605
|
+
if (!info.ok) return previous || null;
|
|
606
|
+
const unchanged = previous && previous.size === info.size && previous.lastOutputAt === info.mtimeMs;
|
|
607
|
+
if (unchanged && (ended || !scan || (scan.size === info.size && runnerScanCaughtUp(scan)))) return previous;
|
|
608
|
+
const windowStart = Math.max(0, info.size - windowBytes);
|
|
609
|
+
const read = await readBytes(path, windowStart, info.size, { fsImpl });
|
|
610
|
+
if (!read.ok) return previous || null;
|
|
611
|
+
const windowHealth = healthOfWindow(read.buffer, read.start);
|
|
612
|
+
// A live log that outgrew the window gets a scan: it only looks for a
|
|
613
|
+
// runner's header (a test run after a long build or install prints its
|
|
614
|
+
// header once) and counts from there. A closing summary needs none.
|
|
615
|
+
if (!scan && !ended && !windowHealth.final && read.start > 0) {
|
|
616
|
+
scans.set(id, scan = createRunnerScan(path));
|
|
617
|
+
}
|
|
618
|
+
let caughtUp = false;
|
|
619
|
+
if (scan && !windowHealth.final) {
|
|
620
|
+
caughtUp = await advanceRunnerScan(scan, info.size, { budgetBytes: budgetLeft, ino: info.ino ?? null, mtimeMs: info.mtimeMs, fsImpl });
|
|
621
|
+
budgetLeft = Math.max(0, budgetLeft - scan.lastReadBytes);
|
|
622
|
+
} else if (scan) {
|
|
623
|
+
// The summary decides now; a scan still behind is of no more use.
|
|
624
|
+
caughtUp = runnerScanCaughtUp(scan);
|
|
625
|
+
if (!caughtUp) {
|
|
626
|
+
scans.delete(id);
|
|
627
|
+
scan = null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
lastOutputAt: info.mtimeMs,
|
|
632
|
+
size: info.size,
|
|
633
|
+
// While the scan catches up the first time (a restart, a big file),
|
|
634
|
+
// the window alone; one that had and is behind this tick, its counts.
|
|
635
|
+
...healthWithScan(windowHealth, read.start, caughtUp || scan?.caughtUpOnce ? scan.tally.result() : null),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// The same file as the last reading saw, with the same facts.
|
|
640
|
+
function sameReading(a, b) {
|
|
641
|
+
if (a === b) return true;
|
|
642
|
+
return !!a && !!b && a.size === b.size && a.lastOutputAt === b.lastOutputAt && sameHealthFacts(a, b);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// The ended rows of `key` read after their end: `{ taskId: reading }` of
|
|
646
|
+
// those whose reading changed.
|
|
647
|
+
async function readEnded(key) {
|
|
648
|
+
const changed = {};
|
|
649
|
+
const rows = store.ended?.(key) || [];
|
|
650
|
+
const done = settledEnded.get(key) || new Map();
|
|
651
|
+
const kept = new Map();
|
|
652
|
+
for (const row of rows) {
|
|
653
|
+
const taskId = String(row?.taskId || '').trim();
|
|
654
|
+
if (!taskId || !taskOutputSource(row).path) continue;
|
|
655
|
+
if (done.get(taskId) === row.endedAt) {
|
|
656
|
+
kept.set(taskId, row.endedAt);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
let reading = null;
|
|
660
|
+
try {
|
|
661
|
+
reading = await readHealth(key, row, row.health || null, { ended: true });
|
|
662
|
+
} catch (error) {
|
|
663
|
+
logger?.warn?.(`[task-output] health read failed for ended task ${taskId.slice(0, 12)}: ${error?.message || error}`);
|
|
664
|
+
}
|
|
665
|
+
if (!reading) continue;
|
|
666
|
+
if (sameReading(row.health, reading)) {
|
|
667
|
+
kept.set(taskId, row.endedAt);
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (store.setEndedHealth?.(key, taskId, reading)) changed[taskId] = reading;
|
|
671
|
+
}
|
|
672
|
+
if (kept.size) settledEnded.set(key, kept);
|
|
673
|
+
else settledEnded.delete(key);
|
|
674
|
+
return changed;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async function tick({ poked: fromPoke = false } = {}) {
|
|
678
|
+
if (!store?.sets) return;
|
|
679
|
+
if (running) {
|
|
680
|
+
if (fromPoke) again = true;
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
running = true;
|
|
684
|
+
scansRead = new Set();
|
|
685
|
+
budgetLeft = scanBytesPerTick;
|
|
686
|
+
const turn = tickNumber;
|
|
687
|
+
tickNumber += 1;
|
|
688
|
+
try {
|
|
689
|
+
// Ended rows of conversations nobody opens again expire here.
|
|
690
|
+
store.pruneAll?.();
|
|
691
|
+
const keys = new Set([...store.sets.keys(), ...(store.endedKeys?.() || [])]);
|
|
692
|
+
for (const key of rotate([...keys], turn)) {
|
|
693
|
+
const tasks = rotate(Array.isArray(store.sets.get(key)?.tasks) ? store.sets.get(key).tasks : [], turn);
|
|
694
|
+
const changed = {};
|
|
695
|
+
let anyChange = false;
|
|
696
|
+
const current = {};
|
|
697
|
+
for (const task of tasks) {
|
|
698
|
+
const taskId = String(task?.taskId || '').trim();
|
|
699
|
+
if (!taskId || !taskOutputSource(task).path) continue;
|
|
700
|
+
let reading = null;
|
|
701
|
+
try {
|
|
702
|
+
reading = await readHealth(key, task, store.health(key, taskId));
|
|
703
|
+
} catch (error) {
|
|
704
|
+
logger?.warn?.(`[task-output] health read failed for task ${taskId.slice(0, 12)}: ${error?.message || error}`);
|
|
705
|
+
}
|
|
706
|
+
if (!reading) continue;
|
|
707
|
+
store.setHealth(key, taskId, reading);
|
|
708
|
+
current[taskId] = reading;
|
|
709
|
+
const last = sent.get(key)?.get(taskId);
|
|
710
|
+
if (!last || !sameHealthFacts(last, reading) || reading.lastOutputAt - last.lastOutputAt >= 60_000) {
|
|
711
|
+
changed[taskId] = reading;
|
|
712
|
+
anyChange = true;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const endedChanged = await readEnded(key);
|
|
716
|
+
if (Object.keys(endedChanged).length) anyChange = true;
|
|
717
|
+
if (!anyChange) continue;
|
|
718
|
+
const record = new Map(sent.get(key) || []);
|
|
719
|
+
for (const [taskId, reading] of Object.entries(changed)) record.set(taskId, reading);
|
|
720
|
+
// Tasks gone from the set leave the record.
|
|
721
|
+
for (const taskId of [...record.keys()]) if (!current[taskId]) record.delete(taskId);
|
|
722
|
+
sent.set(key, record);
|
|
723
|
+
try {
|
|
724
|
+
io?.emit?.('background_tasks_health', { conversationId: key, health: { ...current, ...endedChanged } });
|
|
725
|
+
} catch {}
|
|
726
|
+
}
|
|
727
|
+
for (const key of [...sent.keys()]) if (!store.sets.has(key)) sent.delete(key);
|
|
728
|
+
for (const key of [...settledEnded.keys()]) if (!keys.has(key)) settledEnded.delete(key);
|
|
729
|
+
for (const id of [...scans.keys()]) if (!scansRead.has(id)) scans.delete(id);
|
|
730
|
+
} finally {
|
|
731
|
+
running = false;
|
|
732
|
+
}
|
|
733
|
+
// A poke during the tick: once more, for what it may have missed.
|
|
734
|
+
if (again) {
|
|
735
|
+
again = false;
|
|
736
|
+
await tick();
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
tick,
|
|
742
|
+
/** A task ended: a tick soon rather than within 10 s. */
|
|
743
|
+
poke() {
|
|
744
|
+
if (poked) return;
|
|
745
|
+
poked = setTimeoutImpl(() => {
|
|
746
|
+
poked = null;
|
|
747
|
+
tick({ poked: true }).catch(() => {});
|
|
748
|
+
}, pokeDelayMs);
|
|
749
|
+
poked?.unref?.();
|
|
750
|
+
},
|
|
751
|
+
start() {
|
|
752
|
+
if (timer) return;
|
|
753
|
+
timer = setIntervalImpl(() => { tick().catch(() => {}); }, intervalMs);
|
|
754
|
+
timer?.unref?.();
|
|
755
|
+
},
|
|
756
|
+
stop() {
|
|
757
|
+
if (timer) clearIntervalImpl(timer);
|
|
758
|
+
timer = null;
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
}
|