@oar-sh/oar 0.9.12 → 0.9.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/web-relay/extension.mjs +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The stale-turn sweep: a `processing` row whose worker went silent, or whose
|
|
4
|
+
// turn ran past the max turn duration, goes back to the queue. Which rows
|
|
5
|
+
// qualify is decided by the repository (listRecoverableProcessing); this
|
|
6
|
+
// module decides what happens to them: continuations are torn down, consumed
|
|
7
|
+
// rows fail terminally, rows out of retry budget fail, the rest are requeued
|
|
8
|
+
// and announced.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `failRecoveredRowTerminally(row, reason)`, `failConsumedRowTerminally(row,
|
|
12
|
+
* reason)` and `noteRestartTurnOutcome(row, outcome)` are the runtime's own;
|
|
13
|
+
* `emit(event, payload)` is the socket broadcast.
|
|
14
|
+
*/
|
|
15
|
+
export function createStaleTurnRecovery({
|
|
16
|
+
db,
|
|
17
|
+
stmts,
|
|
18
|
+
emit,
|
|
19
|
+
maxRequeueRetries,
|
|
20
|
+
failRecoveredRowTerminally,
|
|
21
|
+
failConsumedRowTerminally,
|
|
22
|
+
noteRestartTurnOutcome,
|
|
23
|
+
warn = (line) => console.warn(line),
|
|
24
|
+
} = {}) {
|
|
25
|
+
if (!db) throw new Error('createStaleTurnRecovery requires db');
|
|
26
|
+
if (!stmts) throw new Error('createStaleTurnRecovery requires stmts');
|
|
27
|
+
if (typeof emit !== 'function') throw new Error('createStaleTurnRecovery requires emit');
|
|
28
|
+
|
|
29
|
+
// A Stop still pending for a requeued turn is settled: the attempt it was
|
|
30
|
+
// aimed at is gone.
|
|
31
|
+
const settleRecoveredAbortControls = db.prepare(`
|
|
32
|
+
UPDATE relay_control_requests
|
|
33
|
+
SET status = 'failed',
|
|
34
|
+
error = ?,
|
|
35
|
+
updated_at = ?,
|
|
36
|
+
completed_at = ?
|
|
37
|
+
WHERE queue_message_id = ?
|
|
38
|
+
AND type IN ('abort_turn', 'abort_subagent')
|
|
39
|
+
AND status IN ('pending', 'processing')
|
|
40
|
+
`);
|
|
41
|
+
|
|
42
|
+
function recoverProcessingOlderThan(cutoffIso, requeueAtIso, { ceilingBeforeIso = null } = {}) {
|
|
43
|
+
const params = { inactiveBefore: cutoffIso, ceilingBefore: ceilingBeforeIso || null };
|
|
44
|
+
// Stale background-continuation turns are torn down, never requeued —
|
|
45
|
+
// replaying one would deliver the CLI's own continuation as a user prompt.
|
|
46
|
+
const staleContinuations = stmts.listStaleProcessingContinuations?.all?.(params) || [];
|
|
47
|
+
for (const row of staleContinuations) {
|
|
48
|
+
stmts.dropStaleContinuation?.run?.(row.id);
|
|
49
|
+
emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'failed' });
|
|
50
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
51
|
+
// A teardown is a turn ending with no reply; it must be visible in the
|
|
52
|
+
// console log (the only trace of a silent one was `ignored=already_failed`
|
|
53
|
+
// hours later, when the worker's reply arrived for the dead row).
|
|
54
|
+
const lastSeen = row.owner_last_claimed_at || row.processing_at || row.timestamp || null;
|
|
55
|
+
const reason = lastSeen && lastSeen < cutoffIso ? `inactive since ${lastSeen}` : `ceiling (started ${row.processing_at || row.timestamp})`;
|
|
56
|
+
warn(`STALE CONTINUATION ${String(row.id).slice(0, 8)} conv=${String(row.conversation_id).slice(0, 8)} dropped: ${reason}`);
|
|
57
|
+
}
|
|
58
|
+
const listedRows = stmts.listRecoverableProcessing.all(params);
|
|
59
|
+
for (const row of listedRows.filter((entry) => entry?.consumed_at)) {
|
|
60
|
+
failConsumedRowTerminally(row, 'stale-recovery');
|
|
61
|
+
noteRestartTurnOutcome(row, 'cut');
|
|
62
|
+
}
|
|
63
|
+
const allRows = listedRows.filter((entry) => !entry?.consumed_at);
|
|
64
|
+
if (!allRows.length) return [];
|
|
65
|
+
// Enforce the retry budget before requeueing: failing these rows first
|
|
66
|
+
// flips them out of 'processing' so the recovery UPDATE below skips them.
|
|
67
|
+
const overBudget = allRows.filter((row) => Number(row?.retry_count || 0) + 1 >= maxRequeueRetries);
|
|
68
|
+
for (const row of overBudget) {
|
|
69
|
+
failRecoveredRowTerminally(row, 'stale-recovery');
|
|
70
|
+
noteRestartTurnOutcome(row, 'failed');
|
|
71
|
+
}
|
|
72
|
+
const dueRows = allRows.filter((row) => Number(row?.retry_count || 0) + 1 < maxRequeueRetries);
|
|
73
|
+
if (!dueRows.length) return [];
|
|
74
|
+
|
|
75
|
+
// Requeue exactly the listed rows, and announce only the ones the
|
|
76
|
+
// UPDATE took (its re-check of the rule is a safeguard; nothing runs
|
|
77
|
+
// between the listing and here).
|
|
78
|
+
const rows = [];
|
|
79
|
+
const tx = db.transaction(() => {
|
|
80
|
+
const now = new Date().toISOString();
|
|
81
|
+
for (const row of dueRows) {
|
|
82
|
+
if (stmts.recoverProcessingBefore.run({ ...params, id: row.id, requeueAt: requeueAtIso }).changes === 0) continue;
|
|
83
|
+
settleRecoveredAbortControls.run('queue-recovered', now, now, row.id);
|
|
84
|
+
rows.push(row);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
tx();
|
|
88
|
+
if (!rows.length) return [];
|
|
89
|
+
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
emit('message_status', { messageId: row.id, conversationId: row.conversation_id, status: 'pending' });
|
|
92
|
+
noteRestartTurnOutcome(row, 'requeued');
|
|
93
|
+
}
|
|
94
|
+
emit('queue_updated', { recovered: rows.length });
|
|
95
|
+
return rows;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { recoverProcessingOlderThan };
|
|
99
|
+
}
|
|
@@ -140,7 +140,10 @@ export function createSubagentTimelineService({ db, stmts, now = () => new Date(
|
|
|
140
140
|
if (hasFields && headerSupported()) {
|
|
141
141
|
stmts.updateSubagentRunHeader.run({ id: runId, updatedAt: at, ...fields });
|
|
142
142
|
}
|
|
143
|
-
if (status) {
|
|
143
|
+
if (status === 'running' && typeof stmts.reopenSubagentRun?.run === 'function') {
|
|
144
|
+
// Running (again, for a resumed agent): the end of an earlier run goes.
|
|
145
|
+
stmts.reopenSubagentRun.run(at, runId);
|
|
146
|
+
} else if (status) {
|
|
144
147
|
const terminal = status === 'completed' || status === 'failed' || status === 'cancelled';
|
|
145
148
|
stmts.updateSubagentRunStatus?.run?.(status, at, terminal ? at : null, runId);
|
|
146
149
|
}
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* getTaskOutput), cached per task.
|
|
16
16
|
* - the health poller: every 10 s, for live tasks with an output file (and
|
|
17
17
|
* ended ones, read once more after the end), the time of the last output
|
|
18
|
-
* and the test runner's counts
|
|
19
|
-
* `background_tasks_health` when they change.
|
|
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).
|
|
20
22
|
*
|
|
21
23
|
* The worker is trusted (it runs on this host as this user); the guard stops
|
|
22
24
|
* accidents, not an attacker.
|
|
@@ -27,6 +29,7 @@ import fs from 'node:fs';
|
|
|
27
29
|
import {
|
|
28
30
|
TASK_OUTPUT_LIMITS,
|
|
29
31
|
cleanTaskOutput,
|
|
32
|
+
createRunnerTally,
|
|
30
33
|
foldTaskOutput,
|
|
31
34
|
normalizeTaskOutputKind,
|
|
32
35
|
normalizeTaskOutputPath,
|
|
@@ -71,7 +74,7 @@ export async function inspectOutputFile(filePath, { fsImpl = fs } = {}) {
|
|
|
71
74
|
}
|
|
72
75
|
if (stats.isSymbolicLink()) return { ok: false, code: 'symlink' };
|
|
73
76
|
if (!stats.isFile()) return { ok: false, code: 'not-file' };
|
|
74
|
-
return { ok: true, size: stats.size, mtimeMs: stats.mtimeMs };
|
|
77
|
+
return { ok: true, size: stats.size, mtimeMs: stats.mtimeMs, ino: stats.ino };
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
// Reads [start, end) of the file. Opened without following a symlink where
|
|
@@ -341,6 +344,13 @@ export function shapeSnapshot(snapshot) {
|
|
|
341
344
|
|
|
342
345
|
// ─── Health ──────────────────────────────────────────────────────────────────
|
|
343
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
|
+
|
|
344
354
|
/**
|
|
345
355
|
* Runner counts of the window `buffer` that starts at byte `windowStart` of
|
|
346
356
|
* the file: `{ counts, runner, final, firstFailureOffset }`, the offset the
|
|
@@ -359,10 +369,7 @@ export function healthOfWindow(buffer, windowStart) {
|
|
|
359
369
|
while (position < buffer.length) {
|
|
360
370
|
const at = buffer.indexOf(NEWLINE, position);
|
|
361
371
|
const end = at === -1 ? buffer.length : at;
|
|
362
|
-
|
|
363
|
-
// A `\r` redraw keeps its last state, as the console shows it.
|
|
364
|
-
const body = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
|
|
365
|
-
texts.push(stripControlKeepSgr(body.slice(body.lastIndexOf('\r') + 1)));
|
|
372
|
+
texts.push(healthLineText(buffer.subarray(position, end).toString('utf8')));
|
|
366
373
|
offsets.push(windowStart + position);
|
|
367
374
|
position = end + 1;
|
|
368
375
|
}
|
|
@@ -376,6 +383,144 @@ export function healthOfWindow(buffer, windowStart) {
|
|
|
376
383
|
};
|
|
377
384
|
}
|
|
378
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
|
+
|
|
379
524
|
function sameHealthFacts(a, b) {
|
|
380
525
|
if (!a || !b) return false;
|
|
381
526
|
return a.runner === b.runner
|
|
@@ -387,10 +532,15 @@ function sameHealthFacts(a, b) {
|
|
|
387
532
|
/**
|
|
388
533
|
* Every `intervalMs` (10 s) over the live tasks with an output file: `stat`,
|
|
389
534
|
* and when the size or time changed, the last 64 KB parsed for runner
|
|
390
|
-
* counts.
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
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
|
|
394
544
|
* `background_tasks`.
|
|
395
545
|
*
|
|
396
546
|
* Ended rows are read too, until a reading finds the file as the last one
|
|
@@ -403,6 +553,7 @@ export function createTaskHealthPoller({
|
|
|
403
553
|
io,
|
|
404
554
|
intervalMs = 10_000,
|
|
405
555
|
windowBytes = TASK_OUTPUT_LIMITS.healthBytes,
|
|
556
|
+
scanBytesPerTick = 4 * 1024 * 1024,
|
|
406
557
|
fsImpl = fs,
|
|
407
558
|
logger = console,
|
|
408
559
|
setIntervalImpl = setInterval,
|
|
@@ -419,10 +570,31 @@ export function createTaskHealthPoller({
|
|
|
419
570
|
// key → taskId → the `endedAt` of the ended row whose file was read after
|
|
420
571
|
// the end and found unchanged: nothing more to read for it.
|
|
421
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
|
+
}
|
|
422
592
|
|
|
423
|
-
async function readHealth(task, previous) {
|
|
593
|
+
async function readHealth(key, task, previous, { ended = false } = {}) {
|
|
424
594
|
const { path } = taskOutputSource(task);
|
|
425
595
|
if (!path) return null;
|
|
596
|
+
const id = `${key}\u0000${String(task?.taskId || '').trim()}`;
|
|
597
|
+
let scan = existingScan(id, path);
|
|
426
598
|
const info = await inspectOutputFile(path, { fsImpl });
|
|
427
599
|
// A file not there yet (a spool is named before its first byte): no
|
|
428
600
|
// output yet, quiet since the task started.
|
|
@@ -431,14 +603,36 @@ export function createTaskHealthPoller({
|
|
|
431
603
|
return { lastOutputAt: startedAt, size: 0, counts: null, runner: null, final: false, firstFailureOffset: null };
|
|
432
604
|
}
|
|
433
605
|
if (!info.ok) return previous || null;
|
|
434
|
-
|
|
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;
|
|
435
608
|
const windowStart = Math.max(0, info.size - windowBytes);
|
|
436
609
|
const read = await readBytes(path, windowStart, info.size, { fsImpl });
|
|
437
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
|
+
}
|
|
438
630
|
return {
|
|
439
631
|
lastOutputAt: info.mtimeMs,
|
|
440
632
|
size: info.size,
|
|
441
|
-
|
|
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),
|
|
442
636
|
};
|
|
443
637
|
}
|
|
444
638
|
|
|
@@ -464,7 +658,7 @@ export function createTaskHealthPoller({
|
|
|
464
658
|
}
|
|
465
659
|
let reading = null;
|
|
466
660
|
try {
|
|
467
|
-
reading = await readHealth(row, row.health || null);
|
|
661
|
+
reading = await readHealth(key, row, row.health || null, { ended: true });
|
|
468
662
|
} catch (error) {
|
|
469
663
|
logger?.warn?.(`[task-output] health read failed for ended task ${taskId.slice(0, 12)}: ${error?.message || error}`);
|
|
470
664
|
}
|
|
@@ -487,12 +681,16 @@ export function createTaskHealthPoller({
|
|
|
487
681
|
return;
|
|
488
682
|
}
|
|
489
683
|
running = true;
|
|
684
|
+
scansRead = new Set();
|
|
685
|
+
budgetLeft = scanBytesPerTick;
|
|
686
|
+
const turn = tickNumber;
|
|
687
|
+
tickNumber += 1;
|
|
490
688
|
try {
|
|
491
689
|
// Ended rows of conversations nobody opens again expire here.
|
|
492
690
|
store.pruneAll?.();
|
|
493
691
|
const keys = new Set([...store.sets.keys(), ...(store.endedKeys?.() || [])]);
|
|
494
|
-
for (const key of keys) {
|
|
495
|
-
const tasks = Array.isArray(store.sets.get(key)?.tasks) ? store.sets.get(key).tasks : [];
|
|
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);
|
|
496
694
|
const changed = {};
|
|
497
695
|
let anyChange = false;
|
|
498
696
|
const current = {};
|
|
@@ -501,7 +699,7 @@ export function createTaskHealthPoller({
|
|
|
501
699
|
if (!taskId || !taskOutputSource(task).path) continue;
|
|
502
700
|
let reading = null;
|
|
503
701
|
try {
|
|
504
|
-
reading = await readHealth(task, store.health(key, taskId));
|
|
702
|
+
reading = await readHealth(key, task, store.health(key, taskId));
|
|
505
703
|
} catch (error) {
|
|
506
704
|
logger?.warn?.(`[task-output] health read failed for task ${taskId.slice(0, 12)}: ${error?.message || error}`);
|
|
507
705
|
}
|
|
@@ -528,6 +726,7 @@ export function createTaskHealthPoller({
|
|
|
528
726
|
}
|
|
529
727
|
for (const key of [...sent.keys()]) if (!store.sets.has(key)) sent.delete(key);
|
|
530
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);
|
|
531
730
|
} finally {
|
|
532
731
|
running = false;
|
|
533
732
|
}
|