@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.
Files changed (115) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +508 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +479 -157
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +92 -15
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/claude-worker/task-output-discovery.mjs +28 -2
  14. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  15. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  16. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  17. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  18. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  19. package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
  20. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  21. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  22. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  23. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  24. package/server/db-schema.mjs +10 -0
  25. package/server/grok-worker/acp-host-services.mjs +6 -3
  26. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  27. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  28. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  29. package/server/migrations/0018-background-task-rows.mjs +101 -0
  30. package/server/public/app/action-confirmations.js +12 -15
  31. package/server/public/app/api-client.js +60 -4
  32. package/server/public/app/ask-user-view.js +29 -4
  33. package/server/public/app/background-tasks-view.mjs +56 -7
  34. package/server/public/app/bootstrap.js +7 -0
  35. package/server/public/app/conversation-preferences.mjs +10 -4
  36. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  37. package/server/public/app/conversation-view.js +33 -9
  38. package/server/public/app/host-suspend-ui.js +45 -44
  39. package/server/public/app/journal-view.js +9 -3
  40. package/server/public/app/push-settings.js +1 -0
  41. package/server/public/app/question-answer-rule.mjs +15 -0
  42. package/server/public/app/question-multi-select.mjs +9 -2
  43. package/server/public/app/relay-event-view.mjs +5 -1
  44. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  45. package/server/public/app/relay-restart-note-view.mjs +36 -0
  46. package/server/public/app/relay-restart-ui.js +568 -0
  47. package/server/public/app/settings-modal.js +52 -0
  48. package/server/public/app/socket-handlers.js +1 -1
  49. package/server/public/app/task-console-render.mjs +171 -7
  50. package/server/public/app/task-console.mjs +111 -11
  51. package/server/public/app/task-output-text.mjs +15 -6
  52. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  53. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  54. package/server/public/index.html +76 -5
  55. package/server/relay-exit-codes.mjs +6 -0
  56. package/server/relay-self-restart.mjs +6 -1
  57. package/server/relay-tools.md +17 -9
  58. package/server/repositories/message-repository.mjs +32 -28
  59. package/server/repositories/remote-relay-repository.mjs +4 -2
  60. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  61. package/server/routes/ask-user-routes.mjs +41 -7
  62. package/server/routes/messages-routes.mjs +220 -37
  63. package/server/routes/preview-routes.mjs +73 -7
  64. package/server/routes/relay-board-routes.mjs +19 -2
  65. package/server/routes/relay-restart-routes.mjs +73 -0
  66. package/server/routes/sessions-routes.mjs +79 -5
  67. package/server/server-runtime.mjs +335 -218
  68. package/server/services/about-service.mjs +13 -12
  69. package/server/services/background-task-persistence.mjs +133 -0
  70. package/server/services/background-task-store.mjs +240 -13
  71. package/server/services/conversation-model-fallback.mjs +57 -0
  72. package/server/services/cursor-session-root-service.mjs +70 -6
  73. package/server/services/data-dir-resolution.mjs +208 -19
  74. package/server/services/earlier-database-service.mjs +631 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/oar-cli-helpers.mjs +6 -1
  77. package/server/services/oar-state-layout.mjs +107 -0
  78. package/server/services/oar-state-migration-service.mjs +387 -96
  79. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  80. package/server/services/package-data-rescue.mjs +137 -0
  81. package/server/services/preview-proxy-server.mjs +14 -1
  82. package/server/services/preview-registry-service.mjs +3 -2
  83. package/server/services/process-list-service.mjs +2 -1
  84. package/server/services/provider-model-selection.mjs +195 -0
  85. package/server/services/push-dispatch-service.mjs +42 -8
  86. package/server/services/relay-event-service.mjs +210 -14
  87. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  88. package/server/services/relay-restart-picture.mjs +209 -0
  89. package/server/services/relay-restart-queue.mjs +387 -0
  90. package/server/services/relay-shutdown-gate.mjs +4 -1
  91. package/server/services/relay-singleton-guard.mjs +300 -36
  92. package/server/services/remote-relay-caller-context.mjs +7 -2
  93. package/server/services/remote-relay-dispatcher.mjs +217 -28
  94. package/server/services/restart-resume-service.mjs +303 -0
  95. package/server/services/session-activity-tracker.mjs +52 -15
  96. package/server/services/session-worker-launch-service.mjs +31 -1
  97. package/server/services/session-worker-process-service.mjs +6 -4
  98. package/server/services/stale-turn-recovery.mjs +99 -0
  99. package/server/services/subagent-timeline-service.mjs +4 -1
  100. package/server/services/task-output-service.mjs +217 -18
  101. package/server/services/windows-autostart-service.mjs +329 -31
  102. package/server/services/windows-boot-autostart-service.mjs +188 -60
  103. package/shared/preview-instructions.mjs +17 -12
  104. package/shared/preview-tool-core.mjs +32 -14
  105. package/shared/question-answer-rule.mjs +63 -0
  106. package/shared/relay-events.mjs +106 -3
  107. package/shared/remote-relay-contract.mjs +32 -15
  108. package/shared/subagent-timeline.mjs +4 -0
  109. package/shared/task-output-text.mjs +196 -83
  110. package/shared/windows-system-tools.mjs +25 -0
  111. package/shared/worker-runtime/config-loader.mjs +54 -8
  112. package/shared/worker-runtime/prompt-context.mjs +3 -1
  113. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
  114. package/shared/worker-runtime/working-directory.mjs +65 -0
  115. 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 from the last 64 KB, sent to the pages as
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
- const raw = buffer.subarray(position, end).toString('utf8');
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. Readings go into the store (`setHealth`); a key's readings go to
391
- * the pages as `background_tasks_health { conversationId, health }` when the
392
- * counts change, and when the last output moved on by a minute or more (the
393
- * stall hint counts minutes). `conversationId` is the store key, as in
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
- if (previous && previous.size === info.size && previous.lastOutputAt === info.mtimeMs) return previous;
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
- ...healthOfWindow(read.buffer, read.start),
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
  }