fix: background-delivery ground rules — the dock is the source of truth, delivered content shows the moment it persists

The owner's three-part test of the context-injection flow surfaced
timing/visibility bugs, fixed as standing ground rules for ALL background
processes (owner rules, 2026-09-15):

1. THE JOB STARTS AT SEND. triggerGraphContext moves from onFinish to
   right after the write-ahead persist in handleChatRequest: its only
   input is the first typed message (the topic), just made durable — so
   embedding + cosine scan run CONCURRENT with the prober's turn and
   the dock blob appears the moment the session starts, not after turn
   one finishes. Extraction stays at onFinish (it needs answers
   accumulated DURING the turn).

2. DELIVERED CONTENT SHOWS MID-TURN. Steers inserted server-side
   (claimed at turn start, persisted write-ahead) used to wait for the
   next turn's completion to appear — the full record sync mid-stream
   is forbidden (it breaks the SDK's in-place continuation merge), so
   adoption was deferred to idle. New append-only seam: core
   mergeAdopted() inserts the record's new message ids in canonical
   order (in-flight streaming tail kept last) and never rewrites a
   local message; any other shape bails to the full-sync-at-idle net.
   Wired at three points, no new standing polls:
   - turn-start window: three bounded record fetches (400ms/1s/2s)
     after a send catch the claimed steer while the turn streams;
     self-terminating, survives the submitted->streaming flip.
   - jobs-poll settle: mid-turn settles adopt append-only NOW, the
     owed full sync still runs at idle.
   - wake-adoption poll: each tick adopts persisted wake content, so
     steers show at the wake turn's START, not its finish.
   The dock stays honest with zero gap: jobs hold "running" while
   queued behind a pending question ("delivering context"), then flip
   done at claim/delivery — visible from start to delivery, and the
   turn-start nudge (useActivity().refresh) clears it within ~150ms.

3. THE MARKER IS CLICKABLE (observability). The knowledge-context
   SystemLine becomes GraphContextMarker: quiet line, popover with the
   delivered propositions IN DELIVERY ORDER (most similar first — the
   order the budget filled), each with its folded understanding level;
   Esc / outside pointerdown close. The part data was already the audit
   trail — now it's readable at the seam where it landed.

Tests: mergeAdopted unit pins (6), marker render pins (3) — 468/468,
tsc, eslint, build all green; live-smoked end-to-end: blob at send,
marker at t+500ms mid-turn, popover order + levels, instant dock clear.
This commit is contained in:
amosblomqvist
2026-09-15 16:34:25 +03:00
parent 4022e3f0d0
commit 31978f3fc3
7 changed files with 404 additions and 28 deletions
+14 -3
View File
@@ -146,6 +146,20 @@ export async function handleChatRequest(
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
}
// THE GRAPH READ CHANNEL (vision-context-injection.md): one shot per
// chat, at session start — fired HERE, at turn start, not in onFinish:
// the injection's only input is the first typed message (the topic),
// which the write-ahead persist above just made durable, so it runs
// CONCURRENT with the prober's turn (embedding + cosine scan, no model
// call) and the dock shows the job the moment the session starts. Its
// steer then waits in the wake queue through the turn (waitForIdle) —
// exactly the same hold as a sub-agent result finishing mid-turn. All
// gates (exactly-one-text-message topic, budget, one-shot) live in the
// runner module; for every session past its start this is a no-op.
// Extraction stays in onFinish by contrast: it depends on quiz
// answers accumulated DURING the turn.
triggerGraphContext(graphContext, chatId);
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
// message_end events. Every model call's usage lands in the chat record's
// append-only turns log at onFinish — idempotent per turn id, so a
@@ -237,9 +251,6 @@ export async function handleChatRequest(
});
}
triggerExtraction(stores, extraction, chatId);
// The graph READ channel: one-shot prior-knowledge injection at
// session start — a no-op for every session past its start.
triggerGraphContext(graphContext, chatId);
// LAST: a poller that sees not-in-flight must read an already-persisted
// record — endTurn only after persistTurn has done its job.
endTurn(chatId);