mixdog 0.9.53 → 0.9.55

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 (77) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/desktop-session-bridge-test.mjs +47 -0
  5. package/scripts/gemini-provider-test.mjs +396 -1
  6. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  7. package/scripts/interrupted-turn-history-test.mjs +28 -0
  8. package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
  9. package/scripts/pending-completion-drop-test.mjs +160 -4
  10. package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
  11. package/scripts/process-lifecycle-test.mjs +18 -4
  12. package/scripts/prompt-input-parity-test.mjs +145 -0
  13. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  14. package/scripts/provider-contract-test.mjs +91 -33
  15. package/scripts/provider-toolcall-test.mjs +97 -17
  16. package/scripts/streaming-tail-window-test.mjs +146 -0
  17. package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
  18. package/scripts/tui-runtime-load-bench.mjs +45 -0
  19. package/scripts/tui-store-frame-batch-test.mjs +99 -0
  20. package/scripts/tui-transcript-perf-test.mjs +367 -2
  21. package/scripts/write-backpressure-test.mjs +147 -0
  22. package/src/cli.mjs +5 -5
  23. package/src/lib/keychain-cjs.cjs +114 -25
  24. package/src/repl.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
  26. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
  28. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
  32. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
  41. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
  42. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
  43. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  44. package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
  45. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  46. package/src/runtime/memory/lib/trace-store.mjs +66 -4
  47. package/src/runtime/shared/buffered-appender.mjs +83 -4
  48. package/src/runtime/shared/memory-snapshot.mjs +45 -36
  49. package/src/runtime/shared/process-lifecycle.mjs +166 -45
  50. package/src/runtime/shared/process-shutdown.mjs +2 -1
  51. package/src/session-runtime/model-route-api.mjs +4 -1
  52. package/src/session-runtime/native-search.mjs +4 -2
  53. package/src/session-runtime/provider-auth-api.mjs +8 -1
  54. package/src/session-runtime/provider-models.mjs +8 -3
  55. package/src/session-runtime/resource-api.mjs +2 -1
  56. package/src/session-runtime/runtime-core.mjs +32 -0
  57. package/src/session-runtime/session-turn-api.mjs +1 -0
  58. package/src/session-runtime/warmup-schedulers.mjs +5 -1
  59. package/src/standalone/agent-tool.mjs +19 -1
  60. package/src/tui/App.jsx +10 -4
  61. package/src/tui/app/text-layout.mjs +9 -1
  62. package/src/tui/app/transcript-window.mjs +146 -60
  63. package/src/tui/app/use-mouse-input.mjs +16 -8
  64. package/src/tui/app/use-transcript-scroll.mjs +71 -19
  65. package/src/tui/app/use-transcript-window.mjs +21 -10
  66. package/src/tui/components/PromptInput.jsx +13 -6
  67. package/src/tui/dist/index.mjs +1094 -232
  68. package/src/tui/engine/context-state.mjs +31 -31
  69. package/src/tui/engine/frame-batched-store.mjs +75 -0
  70. package/src/tui/engine/session-api-ext.mjs +6 -1
  71. package/src/tui/engine/session-api.mjs +9 -3
  72. package/src/tui/engine/session-flow.mjs +54 -27
  73. package/src/tui/engine/turn.mjs +44 -3
  74. package/src/tui/engine.mjs +515 -36
  75. package/src/tui/input-editing.mjs +33 -13
  76. package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
  77. package/vendor/ink/build/ink.js +8 -16
@@ -0,0 +1,99 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+
4
+ import { createFrameBatchedStorePublisher } from '../src/tui/engine/frame-batched-store.mjs';
5
+
6
+ function harness() {
7
+ let draft = { items: [], structureRevision: 4 };
8
+ let published = draft;
9
+ const listeners = new Set();
10
+ const timers = [];
11
+ let unrefs = 0;
12
+ let cancellations = 0;
13
+ const publisher = createFrameBatchedStorePublisher({
14
+ getState: () => draft,
15
+ publishState: (next) => {
16
+ published = Object.freeze(next);
17
+ draft = { ...next, stats: next.stats ? { ...next.stats } : next.stats };
18
+ },
19
+ listeners,
20
+ setTimer: (fn) => {
21
+ const timer = { fn, cancelled: false, unref: () => { unrefs += 1; } };
22
+ timers.push(timer);
23
+ return timer;
24
+ },
25
+ clearTimer: (timer) => {
26
+ timer.cancelled = true;
27
+ cancellations += 1;
28
+ },
29
+ });
30
+ return {
31
+ publisher,
32
+ listeners,
33
+ timers,
34
+ getState: () => published,
35
+ getDraft: () => draft,
36
+ mutate: (fn) => { draft = fn(draft); },
37
+ getUnrefs: () => unrefs,
38
+ getCancellations: () => cancellations,
39
+ };
40
+ }
41
+
42
+ test('frame publisher preserves mutation order and commits one revision/notification', () => {
43
+ const h = harness();
44
+ const snapshots = [];
45
+ h.listeners.add(() => snapshots.push(h.getState()));
46
+ h.mutate((state) => ({ ...state, items: [...state.items, 'agent-a'] }));
47
+ h.publisher.markStructureChange();
48
+ h.publisher.emit();
49
+ h.mutate((state) => ({ ...state, items: [...state.items, 'tool-b'] }));
50
+ h.publisher.markStructureChange();
51
+ h.publisher.emit();
52
+
53
+ assert.deepEqual(h.getDraft().items, ['agent-a', 'tool-b']);
54
+ assert.equal(h.getDraft().structureRevision, 4);
55
+ assert.deepEqual(h.getState().items, [], 'public snapshot stays on the prior atomic pair before flush');
56
+ assert.equal(h.getState().structureRevision, 4);
57
+ assert.equal(h.timers.length, 1);
58
+ assert.equal(h.getUnrefs(), 1);
59
+ h.timers[0].fn();
60
+ assert.equal(snapshots.length, 1);
61
+ assert.deepEqual(snapshots[0].items, ['agent-a', 'tool-b']);
62
+ assert.equal(snapshots[0].structureRevision, 5);
63
+
64
+ // A getState()-style write targets the detached draft, never the object
65
+ // already handed to useSyncExternalStore.
66
+ h.getDraft().items = ['draft-only'];
67
+ assert.deepEqual(h.getState().items, ['agent-a', 'tool-b']);
68
+ assert.equal(h.getState().structureRevision, 5);
69
+ });
70
+
71
+ test('immediate flush publishes the same terminal batch', async () => {
72
+ const h = harness();
73
+ let notifications = 0;
74
+ h.listeners.add(() => { notifications += 1; });
75
+ h.mutate((state) => ({ ...state, items: ['echo'] }));
76
+ h.publisher.markStructureChange();
77
+ h.publisher.emit();
78
+ h.publisher.flushImmediate();
79
+ await Promise.resolve();
80
+ assert.equal(notifications, 1);
81
+ assert.equal(h.getState().structureRevision, 5);
82
+ });
83
+
84
+ test('dispose publishes pending state once and cancels its timer', () => {
85
+ const h = harness();
86
+ const snapshots = [];
87
+ h.listeners.add(() => snapshots.push(h.getState()));
88
+ h.mutate((state) => ({ ...state, items: ['final'] }));
89
+ h.publisher.markStructureChange();
90
+ h.publisher.emit();
91
+ const timer = h.timers.at(-1);
92
+ h.publisher.dispose();
93
+ assert.equal(snapshots.length, 1);
94
+ assert.deepEqual(snapshots[0], { items: ['final'], structureRevision: 5 });
95
+ assert.equal(timer.cancelled, true);
96
+ assert.equal(h.getCancellations(), 1);
97
+ timer.fn();
98
+ assert.equal(snapshots.length, 1, 'cancelled timer cannot emit after dispose');
99
+ });
@@ -1,10 +1,23 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
5
 
4
- import { createEngineItemMutators, replaceEngineItemsState } from '../src/tui/engine.mjs';
6
+ import {
7
+ TRANSCRIPT_LIVE_ITEM_CAP,
8
+ createEngineItemMutators,
9
+ createTranscriptSpillBuffer,
10
+ refillTranscriptViewOverlap,
11
+ replaceEngineItemsState,
12
+ } from '../src/tui/engine.mjs';
13
+ import { createSessionFlow } from '../src/tui/engine/session-flow.mjs';
5
14
  import { createEngineApiA } from '../src/tui/engine/session-api.mjs';
6
15
  import { createRunTurn } from '../src/tui/engine/turn.mjs';
7
- import { buildTranscriptRowIndexIncremental } from '../src/tui/app/transcript-window.mjs';
16
+ import {
17
+ buildTranscriptRowIndexIncremental,
18
+ transcriptItemsWithStableTail,
19
+ transcriptStructureSignature,
20
+ } from '../src/tui/app/transcript-window.mjs';
8
21
  import {
9
22
  isCompletedTranscriptTail,
10
23
  isCompletedTranscriptTailAppendedThisCommit,
@@ -289,6 +302,61 @@ test('starved Esc recovery settles a non-empty tail exactly once', async () => {
289
302
  assert.equal(assistants[0].streaming, false);
290
303
  });
291
304
 
305
+ test('Esc reclaim preserves spill pages while removing the submitted live item', () => {
306
+ const spill = createTranscriptSpillBuffer();
307
+ const original = Array.from({ length: TRANSCRIPT_LIVE_ITEM_CAP + 20 }, (_, index) => ({
308
+ id: index === TRANSCRIPT_LIVE_ITEM_CAP + 19 ? 'submitted-live' : `esc-${index}`,
309
+ kind: 'user',
310
+ text: `${index}`,
311
+ }));
312
+ let state = {
313
+ items: spill.capLive(original),
314
+ busy: true,
315
+ queued: [],
316
+ streamingTail: null,
317
+ spinner: { active: true },
318
+ };
319
+ let preserveSpill = false;
320
+ const set = (patch) => { state = { ...state, ...patch }; return true; };
321
+ const bag = {
322
+ runtime: { abort: () => true },
323
+ flags: {
324
+ leadTurnEpoch: 1,
325
+ disposed: false,
326
+ activePromptRestore: {
327
+ restorable: true,
328
+ text: 'restore me',
329
+ submittedIds: ['submitted-live'],
330
+ discardExecutionPendingResumeKeys: [],
331
+ requeueEntries: [],
332
+ },
333
+ },
334
+ pending: [],
335
+ listeners: new Set(),
336
+ getState: () => state,
337
+ set,
338
+ replaceItems: (items, options = {}) => {
339
+ preserveSpill = options.preserveSpill === true;
340
+ if (!preserveSpill) spill.reset();
341
+ state = { ...state, items };
342
+ return items;
343
+ },
344
+ denyAllToolApprovals: () => {},
345
+ discardExecutionPendingResume: () => {},
346
+ requeueEntriesFront: () => {},
347
+ clearStreamingTail: () => true,
348
+ settleStreamingTail: () => true,
349
+ };
350
+
351
+ createEngineApiA(bag).abort();
352
+ state = { ...state, busy: false };
353
+ assert.equal(preserveSpill, true);
354
+ assert.equal(state.items.some((item) => item.id === 'submitted-live'), false);
355
+ const older = spill.restoreOlder(state.items);
356
+ assert.ok(older.some((item) => item.id === 'esc-0'), 'older spill history remains reachable');
357
+ spill.dispose();
358
+ });
359
+
292
360
  test('removeNotice-style replacement preserves the live tail and later settles once', () => {
293
361
  let state = {
294
362
  items: [{ id: 'notice', kind: 'notice', text: 'temporary' }],
@@ -374,3 +442,300 @@ test('tool-card revision invalidates the incremental prefix cache', () => {
374
442
  assert.equal(after.rows.length, before.rows.length);
375
443
  assert.equal(cacheRef.current.prefixRevision, 2);
376
444
  });
445
+
446
+ test('signature and streaming row-index fast paths retain O(1) prefix storage', () => {
447
+ const items = Array.from({ length: 200 }, (_, index) => ({
448
+ id: `fixed-${index}`, kind: 'notice', text: `row ${index}`,
449
+ }));
450
+ let tail = { id: 'tail-fast', kind: 'assistant', text: 'one', streaming: true };
451
+ const cacheRef = { current: null };
452
+ const firstIndex = buildTranscriptRowIndexIncremental([...items, tail], {
453
+ cacheRef, prefixRevision: 9,
454
+ });
455
+ const firstTotal = firstIndex.totalRows;
456
+ const prefixRowsArr = cacheRef.current.prefixRowsArr;
457
+ const prefixPrefixRows = cacheRef.current.prefixPrefixRows;
458
+ const beforeSig = transcriptStructureSignature([...items, tail], 80, false, 9);
459
+ tail = { ...tail, text: 'one two three' };
460
+ const secondIndex = buildTranscriptRowIndexIncremental([...items, tail], {
461
+ cacheRef, prefixRevision: 9,
462
+ });
463
+ assert.equal(cacheRef.current.prefixRowsArr, prefixRowsArr);
464
+ assert.equal(cacheRef.current.prefixPrefixRows, prefixPrefixRows);
465
+ assert.equal(firstIndex.totalRows, firstTotal);
466
+ assert.notEqual(firstIndex.prefixRows, secondIndex.prefixRows);
467
+ assert.equal(
468
+ transcriptStructureSignature([...items, tail], 80, false, 10)
469
+ .startsWith('10|'),
470
+ true,
471
+ );
472
+ assert.equal(beforeSig.startsWith('9|'), true);
473
+ });
474
+
475
+ test('tail height changes reuse the settled transcript container', () => {
476
+ const settled = Array.from({ length: 200 }, (_, index) => ({
477
+ id: `settled-${index}`, kind: 'notice', text: `${index}`,
478
+ }));
479
+ const cacheRef = { current: null };
480
+ const before = transcriptItemsWithStableTail(
481
+ settled,
482
+ { id: 'stable-tail', kind: 'assistant', streaming: true, text: 'short' },
483
+ cacheRef,
484
+ );
485
+ const after = transcriptItemsWithStableTail(
486
+ settled,
487
+ { id: 'stable-tail', kind: 'assistant', streaming: true, text: 'much longer text '.repeat(20) },
488
+ cacheRef,
489
+ );
490
+ assert.equal(after, before, 'tail growth must not spread/copy the settled prefix');
491
+ });
492
+
493
+ test('transcript spill caps the live window and restores every item in order', () => {
494
+ const spill = createTranscriptSpillBuffer();
495
+ const original = Array.from({ length: TRANSCRIPT_LIVE_ITEM_CAP + 300 }, (_, index) => ({
496
+ id: `history-${index}`, kind: 'notice', text: `history ${index}`,
497
+ }));
498
+ let live = spill.capLive(original);
499
+ const canonicalLive = live;
500
+ const seen = new Set(live.map((item) => item.id));
501
+ assert.ok(live.length <= TRANSCRIPT_LIVE_ITEM_CAP);
502
+ assert.equal(spill.hasOlder, true);
503
+ while (spill.hasOlder) {
504
+ live = spill.restoreOlder(live);
505
+ for (const item of live) seen.add(item.id);
506
+ for (let index = 1; index < live.length; index++) {
507
+ assert.ok(Number(live[index - 1].id.slice(8)) < Number(live[index].id.slice(8)));
508
+ }
509
+ }
510
+ assert.equal(seen.size, original.length);
511
+ while (spill.hasNewer) {
512
+ const restored = spill.restoreNewer(canonicalLive);
513
+ live = restored?.atLive ? canonicalLive : restored;
514
+ }
515
+ assert.equal(live, canonicalLive, 'paging must return to the authoritative live array');
516
+ assert.deepEqual(live, original.slice(-live.length));
517
+ assert.ok(live.length <= TRANSCRIPT_LIVE_ITEM_CAP);
518
+ spill.reset();
519
+ });
520
+
521
+ test('spill restore succeeds before its asynchronous worker write completes', () => {
522
+ const spill = createTranscriptSpillBuffer({ cap: 2, chunkSize: 1 });
523
+ const live = spill.capLive([
524
+ { id: 'async-old', kind: 'notice', text: 'old' },
525
+ { id: 'async-mid', kind: 'notice', text: 'mid' },
526
+ { id: 'async-new', kind: 'notice', text: 'new' },
527
+ ]);
528
+ const restored = spill.restoreOlder(live);
529
+ assert.equal(restored[0].id, 'async-old');
530
+ assert.deepEqual(restored.map((item) => item.id), ['async-old', 'async-mid', 'async-new']);
531
+ spill.dispose();
532
+ });
533
+
534
+ test('failed reset restores retained spill pages transactionally', () => {
535
+ const spill = createTranscriptSpillBuffer({ cap: 2, chunkSize: 1 });
536
+ const oldLive = spill.capLive([
537
+ { id: 'rollback-old', kind: 'notice', text: 'old' },
538
+ { id: 'rollback-mid', kind: 'notice', text: 'mid' },
539
+ { id: 'rollback-new', kind: 'notice', text: 'new' },
540
+ ]);
541
+ const snapshot = spill.snapshot();
542
+ spill.reset();
543
+ spill.capLive([
544
+ { id: 'replacement-old', kind: 'notice', text: 'replacement' },
545
+ { id: 'replacement-new', kind: 'notice', text: 'replacement' },
546
+ ]);
547
+ assert.equal(spill.restoreSnapshot(snapshot), true);
548
+ assert.equal(spill.restoreOlder(oldLive)[0].id, 'rollback-old');
549
+ spill.dispose();
550
+ });
551
+
552
+ test('burst spill reuses one worker and serializes all page writes', async () => {
553
+ const workers = [];
554
+ const workerFactory = () => {
555
+ const worker = new EventEmitter();
556
+ worker.unref = () => {};
557
+ worker.terminate = () => {};
558
+ worker.postMessage = ({ id }) => {
559
+ queueMicrotask(() => worker.emit('message', { id, ok: true }));
560
+ };
561
+ workers.push(worker);
562
+ return worker;
563
+ };
564
+ const spill = createTranscriptSpillBuffer({ cap: 2, chunkSize: 1, workerFactory });
565
+ spill.capLive(Array.from({ length: 22 }, (_, index) => ({
566
+ id: `burst-${index}`, kind: 'notice', text: `${index}`,
567
+ })));
568
+ await wait(10);
569
+ assert.equal(workers.length, 1);
570
+ assert.equal(spill.workerCount, 1);
571
+ assert.equal(spill.pendingWriteCount, 0);
572
+ spill.dispose();
573
+ });
574
+
575
+ test('terminal spill write failure pins history and warns once', async () => {
576
+ let warnings = 0;
577
+ const workerFactory = () => {
578
+ const worker = new EventEmitter();
579
+ worker.unref = () => {};
580
+ worker.terminate = () => {};
581
+ worker.postMessage = ({ id }) => {
582
+ queueMicrotask(() => worker.emit('message', { id, ok: false, error: 'disk full' }));
583
+ };
584
+ return worker;
585
+ };
586
+ const spill = createTranscriptSpillBuffer({
587
+ cap: 2,
588
+ chunkSize: 1,
589
+ workerFactory,
590
+ onWarning: () => { warnings += 1; },
591
+ });
592
+ const live = spill.capLive([
593
+ { id: 'pinned-old', kind: 'notice', text: 'old' },
594
+ { id: 'pinned-mid', kind: 'notice', text: 'mid' },
595
+ { id: 'pinned-new', kind: 'notice', text: 'new' },
596
+ ]);
597
+ await wait(10);
598
+ assert.equal(spill.pinnedPageCount, 1);
599
+ assert.equal(warnings, 1);
600
+ assert.equal(spill.restoreOlder(live)[0].id, 'pinned-old');
601
+ const afterFailure = [
602
+ { id: 'disabled-a' },
603
+ { id: 'disabled-b' },
604
+ { id: 'disabled-c' },
605
+ { id: 'disabled-d' },
606
+ ];
607
+ assert.equal(spill.disabled, true);
608
+ assert.equal(spill.capLive(afterFailure), afterFailure);
609
+ assert.equal(spill.workerCount, 1, 'disabled spilling must not create more work');
610
+ spill.dispose();
611
+ });
612
+
613
+ test('spill worker exit retries in-flight history on one replacement worker', async () => {
614
+ let spawn = 0;
615
+ const workerFactory = () => {
616
+ const worker = new EventEmitter();
617
+ const thisSpawn = ++spawn;
618
+ worker.unref = () => {};
619
+ worker.terminate = () => {};
620
+ worker.postMessage = ({ id, targetPath, tempPath, items }) => {
621
+ queueMicrotask(() => {
622
+ if (thisSpawn === 1) worker.emit('exit', 1);
623
+ else {
624
+ writeFileSync(tempPath, JSON.stringify(items), 'utf8');
625
+ renameSync(tempPath, targetPath);
626
+ worker.emit('message', { id, ok: true });
627
+ }
628
+ });
629
+ };
630
+ return worker;
631
+ };
632
+ const spill = createTranscriptSpillBuffer({
633
+ cap: 2,
634
+ chunkSize: 1,
635
+ workerFactory,
636
+ writeTimeoutMs: 100,
637
+ });
638
+ const live = spill.capLive([
639
+ { id: 'exit-old', kind: 'notice', text: 'old' },
640
+ { id: 'exit-mid', kind: 'notice', text: 'mid' },
641
+ { id: 'exit-new', kind: 'notice', text: 'new' },
642
+ ]);
643
+ await wait(10);
644
+ assert.equal(spill.workerCount, 2);
645
+ assert.equal(spill.pendingWriteCount, 0);
646
+ assert.equal(spill.pinnedPageCount, 0);
647
+ assert.equal(spill.restoreOlder(live)[0].id, 'exit-old');
648
+ spill.dispose();
649
+ });
650
+
651
+ test('spill attempt exposes history only after atomic rename commit', async () => {
652
+ let firstTarget = '';
653
+ let firstTemp = '';
654
+ let spawn = 0;
655
+ const workerFactory = () => {
656
+ const worker = new EventEmitter();
657
+ const thisSpawn = ++spawn;
658
+ worker.unref = () => {};
659
+ worker.terminate = () => {};
660
+ worker.postMessage = ({ id, targetPath, tempPath, items }) => {
661
+ if (thisSpawn === 1) {
662
+ firstTarget = targetPath;
663
+ firstTemp = tempPath;
664
+ writeFileSync(tempPath, '{"partial":', 'utf8');
665
+ return; // force timeout; the partial attempt never reaches targetPath
666
+ }
667
+ queueMicrotask(() => {
668
+ writeFileSync(tempPath, JSON.stringify(items), 'utf8');
669
+ renameSync(tempPath, targetPath);
670
+ worker.emit('message', { id, ok: true });
671
+ });
672
+ };
673
+ return worker;
674
+ };
675
+ const spill = createTranscriptSpillBuffer({
676
+ cap: 2,
677
+ chunkSize: 1,
678
+ workerFactory,
679
+ writeTimeoutMs: 5,
680
+ });
681
+ const live = spill.capLive([
682
+ { id: 'atomic-old', kind: 'notice', text: 'old' },
683
+ { id: 'atomic-mid', kind: 'notice', text: 'mid' },
684
+ { id: 'atomic-new', kind: 'notice', text: 'new' },
685
+ ]);
686
+ const beforeCommit = spill.restoreOlder(live);
687
+ assert.equal(beforeCommit[0].id, 'atomic-old', 'pending memory serves restore');
688
+ assert.equal(existsSync(firstTemp), true);
689
+ assert.equal(existsSync(firstTarget), false, 'partial attempt is never the committed page');
690
+ await wait(20);
691
+ assert.equal(JSON.parse(readFileSync(firstTarget, 'utf8'))[0].id, 'atomic-old');
692
+ spill.dispose();
693
+ });
694
+
695
+ test('reclaim refills the nearest historical overlap from updated live items', () => {
696
+ const historical = Array.from({ length: 3 }, (_, index) => ({ id: `old-${index}` }));
697
+ const previousLive = Array.from({ length: 70 }, (_, index) => ({ id: `live-${index}` }));
698
+ const view = [...historical, ...previousLive.slice(0, 64)];
699
+ const nextLive = previousLive.filter((item) => item.id !== 'live-0');
700
+ const refilled = refillTranscriptViewOverlap(view, previousLive, nextLive);
701
+ assert.deepEqual(refilled.slice(0, 3), historical);
702
+ assert.deepEqual(refilled.slice(3), nextLive.slice(0, 64));
703
+ assert.equal(refilled.length, 67);
704
+ });
705
+
706
+ test('failed reset restores the historical transcript view', () => {
707
+ let state = {
708
+ items: [{ id: 'live' }],
709
+ transcriptViewItems: [{ id: 'history' }, { id: 'live' }],
710
+ transcriptViewRevision: 7,
711
+ toasts: [],
712
+ queued: [],
713
+ thinking: null,
714
+ spinner: null,
715
+ lastTurn: null,
716
+ busy: false,
717
+ stats: {},
718
+ sessionId: 'session',
719
+ };
720
+ const set = (patch) => { state = { ...state, ...patch }; };
721
+ const flow = createSessionFlow({
722
+ runtime: { id: 'session' },
723
+ flags: {},
724
+ pending: [],
725
+ pendingNotificationKeys: new Set(),
726
+ displayedExecutionNotificationKeys: new Set(),
727
+ getState: () => state,
728
+ set,
729
+ replaceItems: (items) => items,
730
+ snapshotTranscriptSpill: () => ({ token: 1 }),
731
+ restoreTranscriptSpill: () => true,
732
+ syncContextStats: () => {},
733
+ routeState: () => ({}),
734
+ agentStatusState: () => ({}),
735
+ });
736
+ const snapshot = flow.snapshotTuiBeforeSessionReset();
737
+ state = { ...state, transcriptViewItems: null, transcriptViewRevision: 8 };
738
+ flow.restoreTuiAfterFailedSessionReset(snapshot);
739
+ assert.deepEqual(state.transcriptViewItems, [{ id: 'history' }, { id: 'live' }]);
740
+ assert.equal(state.transcriptViewRevision, 7);
741
+ });
@@ -0,0 +1,147 @@
1
+ import assert from 'node:assert/strict'
2
+ import { spawnSync } from 'node:child_process'
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { dirname, join, resolve } from 'node:path'
6
+ import test from 'node:test'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
+
9
+ import {
10
+ TRACE_QUEUE_MAX_PENDING_EVENTS,
11
+ enqueueTraceEvents,
12
+ getTraceQueueStats,
13
+ } from '../src/runtime/memory/lib/trace-store.mjs'
14
+
15
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
16
+ const appenderModuleUrl = pathToFileURL(join(root, 'src/runtime/shared/buffered-appender.mjs')).href
17
+
18
+ test('buffered appender caps stalled writes by dropping oldest bytes', () => {
19
+ const dir = mkdtempSync(join(tmpdir(), 'mixdog-buffered-appender-cap-'))
20
+ const loaderPath = join(dir, 'loader.mjs')
21
+ const delayedFsPath = join(dir, 'delayed-fs-promises.mjs')
22
+ const outputPath = join(dir, 'output.log')
23
+ try {
24
+ writeFileSync(delayedFsPath, `
25
+ import { appendFile as realAppendFile } from 'node:fs/promises'
26
+ export async function appendFile(...args) {
27
+ await new Promise(resolve => setTimeout(resolve, 200))
28
+ return realAppendFile(...args)
29
+ }
30
+ `)
31
+ writeFileSync(loaderPath, `
32
+ export async function resolve(specifier, context, nextResolve) {
33
+ if (specifier === 'node:fs/promises' && context.parentURL === ${JSON.stringify(appenderModuleUrl)}) {
34
+ return { url: new URL('./delayed-fs-promises.mjs', import.meta.url).href, shortCircuit: true }
35
+ }
36
+ return nextResolve(specifier, context)
37
+ }
38
+ `)
39
+ const child = spawnSync(process.execPath, [
40
+ '--experimental-loader', pathToFileURL(loaderPath).href,
41
+ '--input-type=module', '-e',
42
+ `
43
+ import assert from 'node:assert/strict'
44
+ import { readFileSync } from 'node:fs'
45
+ import {
46
+ BUFFERED_APPEND_MAX_BYTES, appendBuffered, drainPathSync, getBufferedAppenderStats,
47
+ } from ${JSON.stringify(appenderModuleUrl)}
48
+ const initial = 'I'.repeat(32 * 1024)
49
+ const old = 'O'.repeat(BUFFERED_APPEND_MAX_BYTES)
50
+ const newest = 'N'.repeat(64 * 1024)
51
+ appendBuffered(process.env.OUTPUT_PATH, initial)
52
+ appendBuffered(process.env.OUTPUT_PATH, old)
53
+ appendBuffered(process.env.OUTPUT_PATH, newest)
54
+ const stats = getBufferedAppenderStats(process.env.OUTPUT_PATH)
55
+ assert.equal(stats.bufferedBytes, BUFFERED_APPEND_MAX_BYTES)
56
+ assert.equal(stats.droppedBytes, newest.length)
57
+ drainPathSync(process.env.OUTPUT_PATH)
58
+ await new Promise(resolve => setTimeout(resolve, 250))
59
+ const written = readFileSync(process.env.OUTPUT_PATH, 'utf8')
60
+ const retainedOld = old.slice(newest.length)
61
+ assert.equal(written, initial + retainedOld + newest + initial)
62
+
63
+ const unicodePath = process.env.UNICODE_OUTPUT_PATH
64
+ const emoji = '😀'
65
+ const unicodeOld = emoji.repeat(BUFFERED_APPEND_MAX_BYTES / Buffer.byteLength(emoji, 'utf8'))
66
+ appendBuffered(unicodePath, initial)
67
+ appendBuffered(unicodePath, unicodeOld)
68
+ appendBuffered(unicodePath, 'x')
69
+ const unicodeStats = getBufferedAppenderStats(unicodePath)
70
+ assert.equal(unicodeStats.bufferedBytes, BUFFERED_APPEND_MAX_BYTES - 3)
71
+ assert.equal(unicodeStats.droppedBytes, 4)
72
+ drainPathSync(unicodePath)
73
+ await new Promise(resolve => setTimeout(resolve, 250))
74
+ assert.equal(readFileSync(unicodePath, 'utf8'), initial + unicodeOld.slice(2) + 'x' + initial)
75
+ assert.equal(getBufferedAppenderStats(unicodePath).droppedBytes, 4)
76
+
77
+ const oversizedPath = process.env.OVERSIZED_OUTPUT_PATH
78
+ const oversized = 'Z'.repeat(BUFFERED_APPEND_MAX_BYTES * 2)
79
+ appendBuffered(oversizedPath, initial)
80
+ appendBuffered(oversizedPath, oversized)
81
+ const oversizedStats = getBufferedAppenderStats(oversizedPath)
82
+ assert.equal(oversizedStats.bufferedBytes, BUFFERED_APPEND_MAX_BYTES)
83
+ assert.equal(oversizedStats.droppedBytes, BUFFERED_APPEND_MAX_BYTES)
84
+ drainPathSync(oversizedPath)
85
+ await new Promise(resolve => setTimeout(resolve, 250))
86
+ assert.equal(readFileSync(oversizedPath, 'utf8'), initial + oversized.slice(-BUFFERED_APPEND_MAX_BYTES) + initial)
87
+
88
+ const oversizedUnicodePath = process.env.OVERSIZED_UNICODE_OUTPUT_PATH
89
+ const oversizedUnicode = emoji.repeat(BUFFERED_APPEND_MAX_BYTES / 2 + 1)
90
+ const oversizedUnicodeBytes = Buffer.byteLength(oversizedUnicode, 'utf8')
91
+ appendBuffered(oversizedUnicodePath, initial)
92
+ appendBuffered(oversizedUnicodePath, oversizedUnicode)
93
+ const oversizedUnicodeStats = getBufferedAppenderStats(oversizedUnicodePath)
94
+ assert.equal(oversizedUnicodeStats.bufferedBytes, BUFFERED_APPEND_MAX_BYTES)
95
+ assert.equal(oversizedUnicodeStats.droppedBytes, oversizedUnicodeBytes - BUFFERED_APPEND_MAX_BYTES)
96
+ drainPathSync(oversizedUnicodePath)
97
+ await new Promise(resolve => setTimeout(resolve, 250))
98
+ assert.equal(readFileSync(oversizedUnicodePath, 'utf8'), initial + oversizedUnicode.slice(-BUFFERED_APPEND_MAX_BYTES / 2) + initial)
99
+ `,
100
+ ], {
101
+ cwd: root,
102
+ encoding: 'utf8',
103
+ env: {
104
+ ...process.env,
105
+ OUTPUT_PATH: outputPath,
106
+ UNICODE_OUTPUT_PATH: join(dir, 'unicode-output.log'),
107
+ OVERSIZED_OUTPUT_PATH: join(dir, 'oversized-output.log'),
108
+ OVERSIZED_UNICODE_OUTPUT_PATH: join(dir, 'oversized-unicode-output.log'),
109
+ },
110
+ timeout: 5_000,
111
+ })
112
+ assert.equal(child.status, 0, child.stderr || child.stdout)
113
+ } finally {
114
+ rmSync(dir, { recursive: true, force: true })
115
+ }
116
+ })
117
+
118
+ test('trace queue caps pending events by dropping oldest entries', async () => {
119
+ let releaseFirstFlush
120
+ const firstFlush = new Promise(resolve => { releaseFirstFlush = resolve })
121
+ const calls = []
122
+ const db = {
123
+ query(sql, params) {
124
+ calls.push({ sql, params })
125
+ return calls.length === 1 ? firstFlush : Promise.resolve()
126
+ },
127
+ }
128
+ const firstBatch = Array.from({ length: 500 }, (_, i) => ({ kind: `first-${i}` }))
129
+ const burst = Array.from(
130
+ { length: TRACE_QUEUE_MAX_PENDING_EVENTS + 64 },
131
+ (_, i) => ({ kind: `burst-${i}` }),
132
+ )
133
+
134
+ enqueueTraceEvents(db, firstBatch)
135
+ enqueueTraceEvents(db, burst)
136
+ const stats = getTraceQueueStats(db)
137
+ assert.equal(stats.pendingEvents, TRACE_QUEUE_MAX_PENDING_EVENTS)
138
+ assert.equal(stats.droppedEvents, 64)
139
+
140
+ releaseFirstFlush()
141
+ await new Promise(resolve => setTimeout(resolve, 250))
142
+ const chunkCalls = calls.slice(1)
143
+ assert.equal(chunkCalls.length, Math.ceil(TRACE_QUEUE_MAX_PENDING_EVENTS / Math.floor(65_535 / 17)))
144
+ assert.ok(chunkCalls.every(call => call.params.length <= 65_535))
145
+ const retainedKinds = chunkCalls.flatMap(call => call.params.filter((_, i) => i % 17 === 3))
146
+ assert.deepEqual(retainedKinds, burst.slice(64).map(event => event.kind))
147
+ })
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
4
4
  import { classifyCliInvocation } from './headless-command.mjs';
5
5
  import {
6
6
  beginProcessLifecycle,
7
- finishProcessLifecycle,
7
+ finishProcessLifecycleAsync,
8
8
  } from './runtime/shared/process-lifecycle.mjs';
9
9
  import { stagedChildExitCode } from './runtime/shared/staged-child-result.mjs';
10
10
 
@@ -54,12 +54,12 @@ async function main() {
54
54
  return await run(argv);
55
55
  }
56
56
 
57
- main().then((code) => {
57
+ main().then(async (code) => {
58
58
  const exitCode = Number.isInteger(code) ? code : 0;
59
- finishProcessLifecycle('clean-shutdown', exitCode);
59
+ await finishProcessLifecycleAsync('clean-shutdown', exitCode);
60
60
  process.exit(exitCode);
61
- }).catch((error) => {
61
+ }).catch(async (error) => {
62
62
  process.stderr.write(`${error?.stack || error?.message || String(error)}\n`);
63
- finishProcessLifecycle('catchable-fatal-error', 1);
63
+ await finishProcessLifecycleAsync('catchable-fatal-error', 1);
64
64
  process.exit(1);
65
65
  });