@oar-sh/oar 0.9.12 → 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/package.json +1 -1
  4. package/server/README.md +186 -11
  5. package/server/claude-worker/claude-session-process.mjs +53 -2
  6. package/server/claude-worker/task-output-discovery.mjs +28 -2
  7. package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
  8. package/server/copilot-worker/copilot-sdk-session-process.mjs +10 -0
  9. package/server/db-schema.mjs +10 -0
  10. package/server/grok-worker/acp-host-services.mjs +6 -3
  11. package/server/grok-worker/grok-background-tasks.mjs +7 -0
  12. package/server/migrations/0018-background-task-rows.mjs +101 -0
  13. package/server/public/app/action-confirmations.js +12 -15
  14. package/server/public/app/api-client.js +57 -3
  15. package/server/public/app/background-tasks-view.mjs +56 -7
  16. package/server/public/app/bootstrap.js +7 -0
  17. package/server/public/app/conversation-preferences.mjs +10 -4
  18. package/server/public/app/conversation-view.js +33 -9
  19. package/server/public/app/host-suspend-ui.js +45 -44
  20. package/server/public/app/journal-view.js +9 -3
  21. package/server/public/app/push-settings.js +1 -0
  22. package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
  23. package/server/public/app/relay-restart-note-view.mjs +36 -0
  24. package/server/public/app/relay-restart-ui.js +568 -0
  25. package/server/public/app/settings-modal.js +52 -0
  26. package/server/public/app/socket-handlers.js +1 -1
  27. package/server/public/app/task-console-render.mjs +171 -7
  28. package/server/public/app/task-console.mjs +111 -11
  29. package/server/public/app/task-output-text.mjs +15 -6
  30. package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
  31. package/server/public/app/transcript-jump-bottom.mjs +20 -0
  32. package/server/public/index.html +67 -0
  33. package/server/relay-tools.md +5 -0
  34. package/server/repositories/message-repository.mjs +3 -0
  35. package/server/repositories/remote-relay-repository.mjs +4 -2
  36. package/server/routes/messages-routes.mjs +155 -27
  37. package/server/routes/relay-board-routes.mjs +19 -2
  38. package/server/routes/relay-restart-routes.mjs +73 -0
  39. package/server/routes/sessions-routes.mjs +75 -3
  40. package/server/server-runtime.mjs +192 -137
  41. package/server/services/about-service.mjs +13 -12
  42. package/server/services/background-task-persistence.mjs +133 -0
  43. package/server/services/background-task-store.mjs +240 -13
  44. package/server/services/conversation-model-fallback.mjs +57 -0
  45. package/server/services/host-suspend-command.mjs +2 -1
  46. package/server/services/preview-proxy-server.mjs +14 -1
  47. package/server/services/process-list-service.mjs +2 -1
  48. package/server/services/provider-model-selection.mjs +195 -0
  49. package/server/services/push-dispatch-service.mjs +9 -2
  50. package/server/services/relay-event-service.mjs +27 -9
  51. package/server/services/relay-prompt-sanitizer.mjs +7 -0
  52. package/server/services/relay-restart-picture.mjs +209 -0
  53. package/server/services/relay-restart-queue.mjs +380 -0
  54. package/server/services/relay-shutdown-gate.mjs +4 -1
  55. package/server/services/remote-relay-dispatcher.mjs +187 -19
  56. package/server/services/restart-resume-service.mjs +303 -0
  57. package/server/services/session-worker-process-service.mjs +6 -4
  58. package/server/services/task-output-service.mjs +217 -18
  59. package/shared/relay-events.mjs +55 -2
  60. package/shared/remote-relay-contract.mjs +15 -8
  61. package/shared/task-output-text.mjs +196 -83
  62. package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
@@ -32,6 +32,8 @@ export const TASK_OUTPUT_LIMITS = Object.freeze({
32
32
  healthBytes: 64 * 1024,
33
33
  lineCharsMax: 4000,
34
34
  outputPathMax: 1024,
35
+ // A shell task's command line on its row.
36
+ commandCharsMax: 2000,
35
37
  // Ended rows: kept this long, at most this many per conversation.
36
38
  endedKeepMs: 60 * 60 * 1000,
37
39
  endedMax: 20,
@@ -269,7 +271,7 @@ function clampByte(value) {
269
271
  }
270
272
 
271
273
  function plainStyle() {
272
- return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
274
+ return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false, inverse: false };
273
275
  }
274
276
 
275
277
  function applySgr(state, params) {
@@ -281,11 +283,13 @@ function applySgr(state, params) {
281
283
  else if (code === 2) state.dim = true;
282
284
  else if (code === 3) state.italic = true;
283
285
  else if (code === 4) state.underline = true;
286
+ else if (code === 7) state.inverse = true;
284
287
  else if (code === 22) {
285
288
  state.bold = false;
286
289
  state.dim = false;
287
290
  } else if (code === 23) state.italic = false;
288
291
  else if (code === 24) state.underline = false;
292
+ else if (code === 27) state.inverse = false;
289
293
  else if (code >= 30 && code <= 37) state.fg = code - 30;
290
294
  else if (code === 39) state.fg = null;
291
295
  else if (code >= 40 && code <= 47) state.bg = code - 40;
@@ -310,8 +314,11 @@ function applySgr(state, params) {
310
314
  /**
311
315
  * One line as styled segments `{ text, fg, bg, bold, dim, italic, underline }`,
312
316
  * colours 0–15 or null (256 and truecolor mapped to the nearest of the 16).
313
- * Adjacent segments never share a style. Any escape that is not SGR is
314
- * dropped from the text.
317
+ * Inverse text (SGR 7, until 27 or 0) comes with its colours already swapped
318
+ * and `inverse: true`: a null `fg` then means the default background as the
319
+ * text colour, a null `bg` the default text colour as the background (the
320
+ * filled RUN / PASS / FAIL badges of vitest). Adjacent segments never share a
321
+ * style. Any escape that is not SGR is dropped from the text.
315
322
  */
316
323
  export function ansiToSegments(lineText) {
317
324
  const text = String(lineText ?? '');
@@ -320,13 +327,17 @@ export function ansiToSegments(lineText) {
320
327
  const pushText = (value) => {
321
328
  const clean = stripControlKeepSgr(value).replace(/\x1b/g, '');
322
329
  if (!clean) return;
330
+ const fg = state.inverse ? state.bg : state.fg;
331
+ const bg = state.inverse ? state.fg : state.bg;
323
332
  const last = segments[segments.length - 1];
324
- if (last && last.fg === state.fg && last.bg === state.bg && last.bold === state.bold
325
- && last.dim === state.dim && last.italic === state.italic && last.underline === state.underline) {
333
+ if (last && last.fg === fg && last.bg === bg && last.bold === state.bold && last.dim === state.dim
334
+ && last.italic === state.italic && last.underline === state.underline && !!last.inverse === state.inverse) {
326
335
  last.text += clean;
327
336
  return;
328
337
  }
329
- segments.push({ text: clean, ...state });
338
+ const segment = { text: clean, fg, bg, bold: state.bold, dim: state.dim, italic: state.italic, underline: state.underline };
339
+ if (state.inverse) segment.inverse = true;
340
+ segments.push(segment);
330
341
  };
331
342
  let at = 0;
332
343
  const pattern = new RegExp(SGR_PATTERN.source, 'g');
@@ -389,15 +400,38 @@ const KNOWN_TOKEN_RULES = Object.freeze([
389
400
  [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/g, '$1 [redacted]'],
390
401
  // A password in a URL: scheme://user:password@host.
391
402
  [/\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1[redacted]@'],
403
+ // A Slack incoming webhook: the path is the credential.
404
+ [/\b(hooks\.slack\.com\/services\/)[A-Za-z0-9_/-]+/g, '$1[redacted]'],
405
+ // Credentials in a query string: ?key=…, &sig=…, &token=…
406
+ [/([?&](?:key|api_?key|sig|signature|token|access_token|auth|password|secret)=)(?!\[redacted\])[^&\s#"'`]+/gi, '$1[redacted]'],
407
+ // An `Authorization:` header, any case (`AUTHORIZATION: basic …` in a git
408
+ // http.extraheader), any scheme word (`token <hex>`, `Bot …`): the word
409
+ // stays, the value goes. Bearer/Basic outside a header stay case-exact
410
+ // above, so prose ("basic functionality") is left alone.
411
+ [/\b(authorization["']?\s*[:=]\s*["']?(?:token|bot|digest|apikey|bearer|basic)\s+)(?!\[redacted\])[^\s"',;]+/gi, '$1[redacted]'],
412
+ // curl -u / --user name:password, also attached (-ualice:pw). Only on a
413
+ // curl line: `docker run -u node:node`, `ps -u alice:staff` are no secret.
414
+ [/\b(curl\b[^\n|;&]*?\s(?:-u|--user)(?:\s+|=)?["']?[^\s:"']+:)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
415
+ // A secret flag's value: --password x, --token=x, --secret x, --api-key x.
416
+ [/((?:^|\s)--(?:password|passwd|token|secret|api-key|access-token|client-secret)(?:\s+|=)["']?)(?!\[redacted\]|\$|-)[^\s"']+/gi, '$1[redacted]'],
417
+ // mysql -p<password> (attached), docker login -p <password>.
418
+ [/\b(mysql\w*\b[^\n|;&]*?\s-p)(?!\[redacted\])([^\s"']+)/g, '$1[redacted]'],
419
+ [/\b(docker\s+login\b[^\n|;&]*?\s-p\s+)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
420
+ // sshpass -p <password> (or attached), redis-cli -a <password>.
421
+ [/\b(sshpass\b[^\n|;&]*?\s-p\s*)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
422
+ [/\b(redis-cli\b[^\n|;&]*?\s-a\s+)(?!\[redacted\]|\$)[^\s"']+/g, '$1[redacted]'],
423
+ // An AWS credentials file or ini line (aws_secret_access_key = …), or
424
+ // `aws configure set aws_secret_access_key <value>`.
425
+ [/\b(aws_secret_access_key|aws_session_token)(\s*[:=]\s*["']?|\s+)(?!\[redacted\]|\$)[^\s"',;]{4,}/gi, '$1$2[redacted]'],
392
426
  ]);
393
427
 
394
428
  // NAME=value with a secret-sounding environment name (API_TOKEN=…,
395
429
  // DB_PASSWORD="…"); the name stays. A value without a letter (a count, a
396
430
  // port) or a placeholder (true, null, ***) is no secret.
397
- const ENV_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH(?!OR))[A-Za-z0-9_]*)=(["']?)(?!\[redacted\]|(?:true|false|null|none|undefined)\b|\*+)(?=[^\s"'`]*[A-Za-z])([^\s"'`]{4,})/gi;
431
+ const ENV_ASSIGNMENT_PATTERN = /\b([A-Za-z0-9_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH(?!OR))[A-Za-z0-9_]*)=(["']?)(?!\[redacted\]|(?:true|false|null|none|undefined)\b|\*+|\$)(?=[^\s"'`]*[A-Za-z])([^\s"'`]{4,})/gi;
398
432
  // `token: value`, `"password": "value"`, `api_key = value`, Authorization: …
399
433
  // A Bearer/Basic value was handled above and keeps its scheme word.
400
- const KEY_VALUE_PATTERN = /\b(token|secret|password|passwd|passphrase|api[_-]?key|access[_-]?key|client[_-]?secret|authorization)(["']?\s*[:=]\s*["']?)(?!(?:Bearer|Basic)\b|\[redacted\]|(?:true|false|null|none|undefined)\b|\*+)(?=[^\s"',;]*[A-Za-z])([^\s"',;]{4,})/gi;
434
+ const KEY_VALUE_PATTERN = /\b(token|secret|password|passwd|passphrase|api[_-]?key|access[_-]?key|client[_-]?secret|authorization)(["']?\s*[:=]\s*["']?)(?!(?:Bearer|Basic|token|bot|digest|apikey)\s|(?:Bearer|Basic)\b|\[redacted\]|(?:true|false|null|none|undefined)\b|\*+|\$)(?=[^\s"',;]*[A-Za-z])([^\s"',;]{4,})/gi;
401
435
  // Anything else token-shaped: 32 or more of [A-Za-z0-9_-] with an upper-case
402
436
  // letter, a lower-case letter and a digit. Random keys have all three; test
403
437
  // names, paths and hex commit ids rarely do.
@@ -433,6 +467,25 @@ export function cleanTaskOutput(text) {
433
467
  return scrubTaskOutput(stripControlKeepSgr(text));
434
468
  }
435
469
 
470
+ /**
471
+ * A worker's text about a task as the relay keeps it on the row (its
472
+ * description, summary, last tool call, command line): no colour or control
473
+ * codes, credentials scrubbed as in the output (before the cut, so a cut
474
+ * never leaves half a secret unrecognised), at most `max` characters. ''
475
+ * when there is none. A description can be the command line itself (Grok's
476
+ * terminals, a Copilot shell without one).
477
+ */
478
+ export function cleanTaskText(value, max) {
479
+ if (typeof value !== 'string' || !value) return '';
480
+ const plain = stripSgr(stripControlKeepSgr(value.slice(0, Math.max(1, max) * 4)));
481
+ return scrubTaskOutput(plain).trim().slice(0, max);
482
+ }
483
+
484
+ /** A shell task's command line as the relay keeps it: cleanTaskText at 2,000 characters. */
485
+ export function normalizeTaskCommand(value) {
486
+ return cleanTaskText(value, TASK_OUTPUT_LIMITS.commandCharsMax);
487
+ }
488
+
436
489
  // ─── Test runners ────────────────────────────────────────────────────────────
437
490
 
438
491
  const PASS_MARK = /^\s*(?:[✓✔√]|\[PASS\])\s+/;
@@ -594,91 +647,98 @@ const RUNNER_HEADERS = [
594
647
  ['turbo', /^\s*•\s+Running \S+ in \d+ packages?/],
595
648
  ];
596
649
 
597
- function liveCounts(runner, texts) {
598
- let passed = 0;
599
- let failed = 0;
600
- let skipped = 0;
601
- let firstFailureLine = null;
602
- const fail = (index, amount = 1) => {
603
- failed += amount;
604
- if (firstFailureLine === null) firstFailureLine = index;
650
+ function newTally() {
651
+ return { passed: 0, failed: 0, skipped: 0, firstFailureLine: null };
652
+ }
653
+
654
+ // One log line of a run of `runner` into `tally`; `at` names the line for
655
+ // `firstFailureLine` (an index into the lines, or a byte offset).
656
+ function countRunnerLine(runner, text, repeat, tally, at) {
657
+ const markFirst = () => {
658
+ if (tally.firstFailureLine === null) tally.firstFailureLine = at;
605
659
  };
606
- texts.forEach((text, index) => {
607
- const repeat = texts.counts[index];
608
- if (runner === 'pytest') {
609
- const verbose = /\s(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)(?:\s+\[\s*\d+%\])?\s*$/.exec(text);
610
- if (verbose) {
611
- if (verbose[1] === 'PASSED' || verbose[1] === 'XPASS') passed += repeat;
612
- else if (verbose[1] === 'FAILED' || verbose[1] === 'ERROR') fail(index, repeat);
613
- else skipped += repeat;
614
- return;
615
- }
616
- // Progress lines: "tests/test_a.py ..F.s [ 40%]"
617
- // and their wrapped continuations ("........ [ 56%]").
618
- const dots = /^\S+\.py\s+([.FEsxX]+)(?:\s+\[\s*\d+%\])?\s*$/.exec(text)
619
- || /^([.FEsxX]+)\s*\[\s*\d+%\]\s*$/.exec(text);
620
- if (dots) {
621
- for (const mark of dots[1]) {
622
- if (mark === '.' || mark === 'X') passed += 1;
623
- else if (mark === 'F' || mark === 'E') fail(index);
624
- else skipped += 1;
625
- }
626
- }
627
- return;
628
- }
629
- if (runner === 'tap' || (runner === 'node:test' && /^\s*(?:not )?ok \d+ /.test(text))) {
630
- const tap = /^\s*(not )?ok \d+\b(.*)$/.exec(text);
631
- if (!tap) return;
632
- // A subtest line is indented; only top-level results count.
633
- if (/^\s/.test(text)) return;
634
- if (/#\s*(?:SKIP|TODO)\b/i.test(tap[2])) skipped += repeat;
635
- else if (tap[1]) fail(index, repeat);
636
- else passed += repeat;
660
+ const fail = (amount = 1) => {
661
+ tally.failed += amount;
662
+ markFirst();
663
+ };
664
+ if (runner === 'pytest') {
665
+ const verbose = /\s(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)(?:\s+\[\s*\d+%\])?\s*$/.exec(text);
666
+ if (verbose) {
667
+ if (verbose[1] === 'PASSED' || verbose[1] === 'XPASS') tally.passed += repeat;
668
+ else if (verbose[1] === 'FAILED' || verbose[1] === 'ERROR') fail(repeat);
669
+ else tally.skipped += repeat;
637
670
  return;
638
671
  }
639
- if (runner === 'vitest') {
640
- // File lines: " ✓ src/a.test.ts (12 tests) 34ms", " ❯ src/b.test.ts (5 tests | 2 failed) 20ms".
641
- // The × lines under a failed file repeat its failures.
642
- const file = /^\s*[✓❯×]\s+\S+\s+\((\d+) tests?(?: \| (\d+) failed)?(?: \| (\d+) skipped)?\)/.exec(text);
643
- if (file) {
644
- const total = Number(file[1]);
645
- const failedHere = Number(file[2] || 0);
646
- const skippedHere = Number(file[3] || 0);
647
- passed += Math.max(0, total - failedHere - skippedHere);
648
- skipped += skippedHere;
649
- if (failedHere) fail(index, failedHere);
650
- return;
672
+ // Progress lines: "tests/test_a.py ..F.s [ 40%]"
673
+ // and their wrapped continuations ("........ [ 56%]").
674
+ const dots = /^\S+\.py\s+([.FEsxX]+)(?:\s+\[\s*\d+%\])?\s*$/.exec(text)
675
+ || /^([.FEsxX]+)\s*\[\s*\d+%\]\s*$/.exec(text);
676
+ if (dots) {
677
+ for (const mark of dots[1]) {
678
+ if (mark === '.' || mark === 'X') tally.passed += 1;
679
+ else if (mark === 'F' || mark === 'E') fail();
680
+ else tally.skipped += 1;
651
681
  }
652
- if (FAIL_MARK.test(text) && firstFailureLine === null) firstFailureLine = index;
653
- return;
654
682
  }
655
- if (runner === 'jest') {
656
- // Without --verbose jest marks files only; each failed test gets a
657
- // "● suite › test" block (but "● Console" is a log block).
658
- if (/^\s*FAIL\s/.test(text)) {
659
- if (firstFailureLine === null) firstFailureLine = index;
660
- return;
661
- }
662
- if (/^\s*●\s(?!Console\b)/.test(text)) {
663
- fail(index, repeat);
664
- return;
665
- }
666
- if (PASS_MARK.test(text)) passed += repeat;
667
- else if (FAIL_MARK.test(text) && firstFailureLine === null) firstFailureLine = index;
683
+ return;
684
+ }
685
+ if (runner === 'tap' || (runner === 'node:test' && /^\s*(?:not )?ok \d+ /.test(text))) {
686
+ const tap = /^\s*(not )?ok \d+\b(.*)$/.exec(text);
687
+ if (!tap) return;
688
+ // A subtest line is indented; only top-level results count.
689
+ if (/^\s/.test(text)) return;
690
+ if (/#\s*(?:SKIP|TODO)\b/i.test(tap[2])) tally.skipped += repeat;
691
+ else if (tap[1]) fail(repeat);
692
+ else tally.passed += repeat;
693
+ return;
694
+ }
695
+ if (runner === 'vitest') {
696
+ // File lines: " ✓ src/a.test.ts (12 tests) 34ms", " ❯ src/b.test.ts (5 tests | 2 failed) 20ms".
697
+ // The × lines under a failed file repeat its failures.
698
+ const file = /^\s*[✓❯×]\s+\S+\s+\((\d+) tests?(?: \| (\d+) failed)?(?: \| (\d+) skipped)?\)/.exec(text);
699
+ if (file) {
700
+ const total = Number(file[1]);
701
+ const failedHere = Number(file[2] || 0);
702
+ const skippedHere = Number(file[3] || 0);
703
+ tally.passed += Math.max(0, total - failedHere - skippedHere);
704
+ tally.skipped += skippedHere;
705
+ if (failedHere) fail(failedHere);
668
706
  return;
669
707
  }
670
- if (runner === 'playwright' && /^\s*-\s+\d+\s/.test(text)) {
671
- skipped += repeat;
708
+ if (FAIL_MARK.test(text)) markFirst();
709
+ return;
710
+ }
711
+ if (runner === 'jest') {
712
+ // Without --verbose jest marks files only; each failed test gets a
713
+ // "● suite › test" block (but "● Console" is a log block).
714
+ if (/^\s*FAIL\s/.test(text)) {
715
+ markFirst();
672
716
  return;
673
717
  }
674
- if (PASS_MARK.test(text)) passed += repeat;
675
- else if (FAIL_MARK.test(text)) {
676
- // node:test's "✖ failing tests:" heads the repeat of the failures.
677
- if (/failing tests:\s*$/.test(text)) return;
678
- fail(index, repeat);
718
+ if (/^\s*●\s(?!Console\b)/.test(text)) {
719
+ fail(repeat);
720
+ return;
679
721
  }
680
- });
681
- return { passed, failed, skipped, firstFailureLine };
722
+ if (PASS_MARK.test(text)) tally.passed += repeat;
723
+ else if (FAIL_MARK.test(text)) markFirst();
724
+ return;
725
+ }
726
+ if (runner === 'playwright' && /^\s*-\s+\d+\s/.test(text)) {
727
+ tally.skipped += repeat;
728
+ return;
729
+ }
730
+ if (PASS_MARK.test(text)) tally.passed += repeat;
731
+ else if (FAIL_MARK.test(text)) {
732
+ // node:test's "✖ failing tests:" heads the repeat of the failures.
733
+ if (/failing tests:\s*$/.test(text)) return;
734
+ fail(repeat);
735
+ }
736
+ }
737
+
738
+ function liveCounts(runner, texts) {
739
+ const tally = newTally();
740
+ texts.forEach((text, index) => countRunnerLine(runner, text, texts.counts[index], tally, index));
741
+ return tally;
682
742
  }
683
743
 
684
744
  // Where the first failure is told in detail, for a run that has its final
@@ -735,3 +795,56 @@ export function parseRunnerSummary(lines) {
735
795
  if (!runner) return null;
736
796
  return { runner, ...liveCounts(runner, texts), final: false };
737
797
  }
798
+
799
+ // One-line closing summaries (jest, vitest, pytest, turbo, node:test's
800
+ // last line, Playwright's timed count).
801
+ const CLOSING_LINES = [
802
+ /^\s*Tests:\s+.*\b\d+\s+total\s*$/,
803
+ /^\s*Tests\s{2,}.*\(\d+\)\s*$/,
804
+ /^=+ .*\bin [\d.]+s(?: \([^)]*\))? =+\s*$/,
805
+ /^\s*Tasks:\s+\d+ successful/,
806
+ /^\s*(?:ℹ|#)\s+duration_ms\s+[\d.]+\s*$/,
807
+ /^\s{2}\d+ passed \([\dhms.\s]+\)\s*$/,
808
+ ];
809
+
810
+ // Headers that open a new run: a second run in the same log (a watch rerun,
811
+ // `npm test` twice) counts from its own start. A jest or node:test file line
812
+ // is a header too, but every file of a run prints one.
813
+ function opensRun(runner, text) {
814
+ if (runner === 'node:test') return /^TAP version \d+/.test(text);
815
+ return runner === 'playwright' || runner === 'vitest' || runner === 'pytest' || runner === 'turbo';
816
+ }
817
+
818
+ /**
819
+ * The live counts of a whole log fed one line at a time, for a log too long
820
+ * to parse at once (the health poller keeps one per task as the file grows).
821
+ * `push(line, at)` takes a completed line (a string or `{ text, count }`) and
822
+ * what names it (a byte offset); `result()` is what parseRunnerSummary says
823
+ * of a run still going, `{ runner, passed, failed, skipped, firstFailureLine,
824
+ * final: false }` with `firstFailureLine` the `at` of the first failure, or
825
+ * null while no runner's header has been seen. Counting starts at the
826
+ * header: marks before it (a build's ✓ lines) are not a test run's.
827
+ */
828
+ export function createRunnerTally() {
829
+ let runner = null;
830
+ let tally = newTally();
831
+ // A closing summary was seen: the next header (a jest file line too)
832
+ // starts the next run.
833
+ let closed = false;
834
+ return {
835
+ push(line, at) {
836
+ const text = lineTextOf(line);
837
+ const header = RUNNER_HEADERS.find(([, pattern]) => pattern.test(text));
838
+ if (header && (!runner || closed || opensRun(header[0], text))) {
839
+ runner = header[0];
840
+ tally = newTally();
841
+ closed = false;
842
+ }
843
+ if (runner) countRunnerLine(runner, text, countOf(line), tally, at);
844
+ if (runner && CLOSING_LINES.some((pattern) => pattern.test(text))) closed = true;
845
+ },
846
+ result() {
847
+ return runner ? { runner, ...tally, final: false } : null;
848
+ },
849
+ };
850
+ }
@@ -69,6 +69,15 @@ export function createWorkerWebSocketLink({
69
69
  minBackoffMs = 1000,
70
70
  maxBackoffMs = 8_000,
71
71
  jitterMs = 250,
72
+ // A connection counts as made only at the relay's first server.hello (the
73
+ // relay sends it as soon as it accepts the socket). One still connecting,
74
+ // or open but silent, when this runs out is given up and retried: a relay
75
+ // that hangs in its shutdown can hold a socket in either state without
76
+ // ever ending it.
77
+ connectTimeoutMs = 15_000,
78
+ // Failed attempts after the first few are logged at most once per this
79
+ // interval, with a count of the ones in between.
80
+ failureLogIntervalMs = 60_000,
72
81
  readyRefreshMs = 10_000,
73
82
  staleConnectionMs = 30_000,
74
83
  WebSocketImpl = globalThis.WebSocket,
@@ -84,6 +93,10 @@ export function createWorkerWebSocketLink({
84
93
  let readyRefreshTimer = null;
85
94
  let reconnectAttempt = 0;
86
95
  let reconnectDelayMs = 0;
96
+ // The current socket's connect deadline (see connectTimeoutMs).
97
+ let connectDeadlineTimer = null;
98
+ let lastFailureLoggedAt = null;
99
+ let failuresNotLogged = 0;
87
100
  // Ends the current socket's connection (see connect).
88
101
  let endCurrentSocket = null;
89
102
  // Insertion-ordered: the first entry is the oldest in-flight delivery, which
@@ -239,6 +252,12 @@ export function createWorkerWebSocketLink({
239
252
  reconnectTimer = null;
240
253
  }
241
254
 
255
+ function clearConnectDeadline() {
256
+ if (!connectDeadlineTimer) return;
257
+ clearTimeoutImpl(connectDeadlineTimer);
258
+ connectDeadlineTimer = null;
259
+ }
260
+
242
261
  function clearReadyRefreshTimer() {
243
262
  if (!readyRefreshTimer) return;
244
263
  clearIntervalImpl(readyRefreshTimer);
@@ -290,7 +309,23 @@ export function createWorkerWebSocketLink({
290
309
  if (typeof readyRefreshTimer?.unref === "function") readyRefreshTimer.unref();
291
310
  }
292
311
 
293
- function scheduleReconnect() {
312
+ // One line per failed attempt for the first few, then at most one per
313
+ // failureLogIntervalMs: a relay that stays away for an hour must not fill
314
+ // the worker log with a line every 8 seconds.
315
+ function logFailedAttempt(reason) {
316
+ const nowMs = getNowMs();
317
+ const intervalMs = Math.max(0, Number(failureLogIntervalMs) || 0);
318
+ if (reconnectAttempt > 3 && lastFailureLoggedAt !== null && (nowMs - lastFailureLoggedAt) < intervalMs) {
319
+ failuresNotLogged += 1;
320
+ return;
321
+ }
322
+ const notLogged = failuresNotLogged ? [`notLogged=${failuresNotLogged}`] : [];
323
+ dbg("worker ws disconnected", reason, `attempt=${reconnectAttempt}`, `retryInMs=${reconnectDelayMs}`, ...notLogged);
324
+ lastFailureLoggedAt = nowMs;
325
+ failuresNotLogged = 0;
326
+ }
327
+
328
+ function scheduleReconnect(reason) {
294
329
  if (stopped) return;
295
330
  // One timer at a time: a second end reported for the same outage must not
296
331
  // stack another attempt or push the backoff up.
@@ -299,7 +334,7 @@ export function createWorkerWebSocketLink({
299
334
  const backoff = Math.min(maxBackoffMs, minBackoffMs * (2 ** Math.max(0, reconnectAttempt - 1)));
300
335
  const jitter = Math.floor(Math.random() * Math.max(0, jitterMs));
301
336
  reconnectDelayMs = Math.min(maxBackoffMs, backoff + jitter);
302
- dbg("worker ws reconnect scheduled", `attempt=${reconnectAttempt}`, `delayMs=${reconnectDelayMs}`);
337
+ logFailedAttempt(reason);
303
338
  reconnectTimer = setTimeoutImpl(() => {
304
339
  reconnectTimer = null;
305
340
  connect();
@@ -312,42 +347,70 @@ export function createWorkerWebSocketLink({
312
347
  const openState = resolveSocketStateValue(ws, "OPEN", 1);
313
348
  const connectingState = resolveSocketStateValue(ws, "CONNECTING", 0);
314
349
  if (ws.readyState === openState || ws.readyState === connectingState) return;
350
+ // A socket left closing whose end was never reported: give it up
351
+ // quietly, this connect is its reconnect.
352
+ endCurrentSocket?.("replaced", { reconnect: false });
315
353
  }
316
354
  if (typeof WebSocketImpl !== "function") {
317
355
  dbg("worker ws unavailable: global WebSocket is missing");
318
356
  return;
319
357
  }
320
358
  clearReconnectTimer();
321
- const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
322
- const socket = new WebSocketImpl(url);
359
+ let socket = null;
360
+ try {
361
+ const url = toWebSocketUrl(serverUrl, token, getSessionId, getPid);
362
+ socket = new WebSocketImpl(url);
363
+ } catch (error) {
364
+ // A socket that fails in its constructor raises no events: nothing but
365
+ // this would ever try again.
366
+ scheduleReconnect(`connect-failed: ${error?.message || String(error)}`);
367
+ return;
368
+ }
323
369
  ws = socket;
324
370
  let serverHelloSeen = false;
325
- // Set once this socket's end has been handled, by whichever of error and
326
- // close comes first; every later event from it is ignored. Node 22 fires
327
- // no close for a socket closed while still connecting, and closing it
328
- // from its own error handler raised the error again, so the reconnect
329
- // has to be scheduled from the error as well, and only once.
371
+ // Set once this socket's end has been handled, by whichever of error,
372
+ // close, the connect deadline or a stale/draining close comes first;
373
+ // every later event from it is ignored. Node 22 fires no close for a
374
+ // socket closed while still connecting, and closing it from its own
375
+ // error handler raised the error again, so the reconnect has to be
376
+ // scheduled from the error as well, and only once.
330
377
  let ended = false;
331
- function endSocket(reason) {
378
+ function endSocket(reason, { reconnect = true } = {}) {
332
379
  if (ended) return false;
333
380
  ended = true;
381
+ if (endCurrentSocket === endSocket) {
382
+ endCurrentSocket = null;
383
+ clearConnectDeadline();
384
+ }
334
385
  if (ws === socket) {
335
386
  ws = null;
336
387
  clearReadyRefreshTimer();
337
388
  // The next connection may reach a different (restarted) relay.
338
389
  serverCapabilities = new Set();
339
390
  }
340
- if (stopped) return true;
341
- dbg("worker ws disconnected", reason);
342
- scheduleReconnect();
391
+ if (stopped || !reconnect) return true;
392
+ // A newer socket is already under way and owns the reconnect.
393
+ if (ws) return true;
394
+ scheduleReconnect(reason);
343
395
  return true;
344
396
  }
345
397
  endCurrentSocket = endSocket;
398
+ clearConnectDeadline();
399
+ const deadlineMs = Math.max(0, Number(connectTimeoutMs) || 0);
400
+ if (deadlineMs > 0) {
401
+ connectDeadlineTimer = setTimeoutImpl(() => {
402
+ connectDeadlineTimer = null;
403
+ if (ended) return;
404
+ const opened = socket.readyState === resolveSocketStateValue(socket, "OPEN", 1);
405
+ // Ended before the close call, as in the error handler below.
406
+ endSocket(opened ? "no-server-hello" : "connect-timeout");
407
+ try { socket.close?.(); } catch {}
408
+ }, deadlineMs);
409
+ if (typeof connectDeadlineTimer?.unref === "function") connectDeadlineTimer.unref();
410
+ }
346
411
  socket.addEventListener("open", () => {
347
412
  if (ended) return;
348
413
  const openedAt = getNowMs();
349
- reconnectAttempt = 0;
350
- reconnectDelayMs = 0;
351
414
  lastOpenAt = openedAt;
352
415
  lastMessageAt = openedAt;
353
416
  dbg("worker ws connected");
@@ -373,6 +436,14 @@ export function createWorkerWebSocketLink({
373
436
  serverCapabilities = new Set(Array.isArray(payload.capabilities) ? payload.capabilities.map(String) : []);
374
437
  if (!serverHelloSeen) {
375
438
  serverHelloSeen = true;
439
+ // The handshake is complete: only now does the backoff start over.
440
+ // A relay that accepts the socket and drops it before its hello
441
+ // keeps backing off instead of being retried every second.
442
+ clearConnectDeadline();
443
+ reconnectAttempt = 0;
444
+ reconnectDelayMs = 0;
445
+ lastFailureLoggedAt = null;
446
+ failuresNotLogged = 0;
376
447
  try {
377
448
  onServerHello(String(payload.reason || "server-hello"));
378
449
  } catch (error) {
@@ -388,6 +459,9 @@ export function createWorkerWebSocketLink({
388
459
  }
389
460
  if (payload?.type === "server.draining") {
390
461
  dbg("worker ws draining", payload.reason || "relay-shutdown");
462
+ // A relay stuck in its shutdown may never complete the close
463
+ // handshake: the reconnect does not wait for the close event.
464
+ endSocket("draining");
391
465
  try { socket.close?.(); } catch {}
392
466
  return;
393
467
  }
@@ -408,15 +482,16 @@ export function createWorkerWebSocketLink({
408
482
  dbg("worker ws blocked", payload.reason || "blocked");
409
483
  }
410
484
  });
411
- socket.addEventListener("close", () => {
412
- endSocket("close");
485
+ socket.addEventListener("close", (event) => {
486
+ const code = Number(event?.code);
487
+ endSocket(Number.isFinite(code) && code > 0 ? `close code=${code}` : "close");
413
488
  });
414
- socket.addEventListener("error", (error) => {
489
+ socket.addEventListener("error", (event) => {
415
490
  if (ended) return;
416
- dbg("worker ws error", error?.message || String(error));
491
+ const detail = event?.message || event?.error?.message || "unknown";
417
492
  // Ended before the close call, so an error or close it raises
418
493
  // (synchronously on some runtimes) finds the socket already handled.
419
- endSocket("error");
494
+ endSocket(`error: ${detail}`);
420
495
  try { socket.close?.(); } catch {}
421
496
  });
422
497
  }
@@ -429,6 +504,7 @@ export function createWorkerWebSocketLink({
429
504
  function stop() {
430
505
  stopped = true;
431
506
  clearReconnectTimer();
507
+ clearConnectDeadline();
432
508
  clearReadyRefreshTimer();
433
509
  if (ws) {
434
510
  try { ws.close(); } catch {}