diff options
| author | Adam Malczewski <[email protected]> | 2026-06-29 00:56:07 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-29 00:56:07 +0900 |
| commit | ebb29b1e5b929493315469fcbd5eca6f13bd1c32 (patch) | |
| tree | 3be897eda7c2ab3c41395b76e7ba9fb24d30c463 /packages/session-orchestrator/src/queue.test.ts | |
| parent | a5855cad41b2e4b0a196e52cd4c1ca5d4c0ca25f (diff) | |
| download | dispatch-ebb29b1e5b929493315469fcbd5eca6f13bd1c32.tar.gz dispatch-ebb29b1e5b929493315469fcbd5eca6f13bd1c32.zip | |
fix(in-flight-compaction): persist steering messages + use live messages array for compaction
Fix two critical bugs found in code review:
Bug A — Permanent loss of mid-turn steering messages:
drainSteering injected queued messages into the kernel's in-memory messages
array but never persisted them, so a user could never see them and in-flight
compaction (which loaded the store) scrubbed them. Fix: drainSteering now
persists the steering message to the store as part of the same critical section
as the injection (await store.append). This required making drainSteering async
— the kernel now awaits it (contract: return type allows Promise; backward-
compatible with sync callbacks). Fire-and-forget was unsafe: the conversation-
store append reads the seq counter then writes chunks across multiple awaits,
so a concurrent steering append + next-step onStepComplete append would both
read the same seq counter and collide (the msgIdx-collision class of bug).
Bug B — Index misalignment (DB <-> LLM divergence):
keepLastN was sliced independently from the store's array (no steering) and
the kernel's array (with steering), so the slices dropped DIFFERENT messages.
Fix (follows from A): performCompaction accepts the kernel's LIVE messages
array instead of reloading the stale store; the SAME recentKept slice is used
for both the store write (replaceHistory) and the value returned to the kernel
(compactedMessages), so they stay byte-aligned by construction. The post-seal/
manual compact() path still loads the store (the turn has ended, so it is
stable).
Tests: kernel async-drainSteering-await contract test; orchestrator steering-
persisted + store/LLM-alignment regression test; queue.test.ts asserts the
steering is persisted; its fake runTurn now awaits drainSteering.
Verification: typecheck clean; 2014 tests pass (was 2012; +2 new + 1 assertion);
biome 0 errors (12 pre-existing warnings in untouched files).
Diffstat (limited to 'packages/session-orchestrator/src/queue.test.ts')
| -rw-r--r-- | packages/session-orchestrator/src/queue.test.ts | 16 |
1 files changed, 15 insertions, 1 deletions
diff --git a/packages/session-orchestrator/src/queue.test.ts b/packages/session-orchestrator/src/queue.test.ts index a09a441..216359f 100644 --- a/packages/session-orchestrator/src/queue.test.ts +++ b/packages/session-orchestrator/src/queue.test.ts @@ -217,7 +217,9 @@ function createDrainingCaptureRunTurn(): { captured.push(input); if (input.drainSteering !== undefined) { drainCalled = true; - const drained = input.drainSteering(); + // The kernel awaits drainSteering (it may return a Promise that + // persists the injected messages); the fake mirrors that. + const drained = await input.drainSteering(); drainedMessages.push(...drained); } return { @@ -332,6 +334,18 @@ describe("drainSteering", () => { expect(steering?.conversationId).toBe("conv-drain"); expect(steering?.text).toBe("first\n\nsecond"); expect(steering?.turnId).toMatch(/^turn-/); + + // The steering message was PERSISTED to the store (not just injected into + // the kernel's in-memory array). Without this, a user could never see the + // steering message, and in-flight compaction (which uses the live messages) + // would be the only thing keeping it — but only if it fired. + const stored = store.data.get("conv-drain") ?? []; + const storedSteering = stored.find( + (m) => + m.role === "user" && + m.chunks.some((c) => c.type === "text" && c.text === "first\n\nsecond"), + ); + expect(storedSteering).toBeDefined(); }); it("drainSteering on an empty queue returns [] and emits nothing", async () => { |
