Repository navigation
Description
Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.
Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 0be15a7b06a04034b81a0b3e8daf2294
Branch: dev/3-busy-ack-ledger
Base: 6a33d0e
At: 2026-10-29T12:08:58Z
Two shapes of the same gap: the busy ack from #1681 (#1678) is right about when it speaks and wrong about what it counts. On a real runtime it says the same line twice, and for the room mid-line it never speaks at all.
Measured on the real runtime (DSH 0.2.0-rc.2, own chat, a 20 s tool), times in ms from the line's arrival:
agent/inbox/splicedidc59bab75at 4836 (targetnext-turn, sourcekind: 'user') → ack call 1 at 4854tool/result20233,turn/end(completed) 20426,turn/start20432- the claim
user/messagewith the same id at 20523 → ack call 2 at 20540, same line, same text
So one person's line costs two model calls and two ack sentences. That is exactly the case #1678 was opened for: a next-turn line is always claimed after a turn/end (DSH appends it in finally), so the "the claim reuses the splice's ack" branch never applies to it.
The room mid-line fails the other way: a human's line to a speaking member is relayed as a steer (core/src/host/groups.js:557 steer: true → core/src/host/delivery.js:228 if (steer) agent.steer(message)), so it is spliced with target: 'next-step'. The new branch returns early on anything that is not next-turn (core/src/host/busy-ack.js:183), so the member's row keeps {text: '', at: 0} and no ack is composed at arrival; the sentence waits for the claim, which is the silence #1646/#1678 were about.
Severity (what a person actually sees)
Read from the client: BusyAckBubble draws one ack bubble per Session from busyAcks[sessionId], portalled into the running row; there is no transcript line and no text/adjacent merging, and busyAckVisible hides the bubble as soon as streamingText is non-empty. So a second entry never becomes a second bubble or a second chat line.
- Certain: one extra model call per waiting line, and the entry's
atis reset (cost + the bookkeeping record). - Possible: the same sentence re-appears in the running row for ~1-3 s, only when the ack call returns before this turn's first token; with a fast mock the whole window is ~78 ms, and with a real model the bubble is hidden the moment streaming starts.
- Unrelated to the steering: the 60s let-go never fires — a human line arrives as agent/inbox/spliced, not user/message #1673 long-wait hook: that only fires after ~60 s, and this reproduces with a 20 s tool.
Fix it anyway: the cost is per line and silent, and the next-turn shape produces it every time.
Root cause (blob at 6a33d0eb80714ac35202077cbe9f674996d677bd)
core/src/host/busy-ack.js:
:162the entry that holds a line id is{ text: '', at, id, lines: new Set() }— the id ledger lives inside the ack entry.:153the dedupe isif (lineId !== undefined && existing.lines.has(lineId)) return existing— it can only fire while that entry still exists.:209if (event.type === 'turn/end') clear(id)— andclearis:172acks.delete(sessionId), so the ledger goes with it.:212-216clears onagent/status === 'idle'too.:182-192the arrival branch requiresevent.data?.target === 'next-turn'; the room condition (room && turnRunning(id)) can therefore only be reached for anext-turnroom splice, which the busy-member path never produces (groups.js:557steers).:194-203the claim branch is fine on its own; it just starts a fresh entry because:209already emptied the map.
Why the shipped test is green: core/test/busy-ack-arrival.test.js drives a synthetic host and emits the claim without a turn/end in between — an order the loop never produces. Its 768 s claim test is the same shape.
Expected behaviour
A. One line is acknowledged at most once: at most one ack model call and at most one sentence per line id, no matter how many turn/end / agent/status: idle events fall between the splice and the claim. The "this line already has an ack" memory must outlive the ack entry's own lifetime, and must stay bounded.
B. A person's line that arrives while a member is speaking in a room must get its ack at arrival, the same as an own-chat line. If the steer shape genuinely cannot be acked at arrival, the code must say so in one place and the row must not claim otherwise; do not leave roomBusyAcksView publishing {text: '', at: 0} for a shape that never fills.
Acceptance
- Real order reproduced red first:
agent/inbox/spliced(id X,next-turn,kind: 'user') →tool/result→turn/end→turn/start→user/messagewith id X. Before the fix: 2 ack calls (assert on the call count, not only on the sentences). After: 1 call, andbusyAcksViewholds exactly one entry for that session whose text is non-empty. - The same order twice in a row with two different line ids keeps one call per line (2 lines → 2 calls, not 4), and never re-speaks line X.
- The ledger is bounded and self-pruning: N+1 distinct lines on one session must not grow it without limit (
LINE_IDS_KEPT-style cap or a time-based prune, whichever you pick), and the prune must not resurrect an old line. - Room mid-line: with the real relay shape (
groups.js:557→delivery.js:228,role: 'room', nosenderSessionId,steer: true) a line arriving while the member speaks produces one ack call at arrival and the member's row carries its text; a Bot's own room line and a friend's Bot line still produce none (isHumanRoomLine,busy-ack.js:61-66). next-stephandling does not double-ack: the same steered line, when it is later admitted asuser/message, must not add a second call (it may reuse the same ledger).- Idle and non-user sources are unchanged: no turn running → 0 calls;
kind: 'bot'/schedule/time-context→ 0 calls. - The new tests must use the real event order above (a
turn/endbetween splice and claim); the synthetic no-turn/endshape may stay as one extra case but must not be the only green.
Neighbour, pre-existing and not introduced by #1681: a member's slot line reaches the Session as role: 'room' with kind: 'bot' and no senderSessionId, so isHumanRoomLine (busy-ack.js:61-66) counts it as a person's line and its claim seeds an empty ack entry on the member's row. The row dot is therefore not proof of a person's line. Do not widen the fix into a rewrite of the slot path, but: if your change touches isHumanRoomLine or the room branch, add the slot-line case to the tests and say in one sentence what you decided; if you leave it, record it in the PR body as a known neighbour so the next reader is not misled.
Scope
core/src/host/busy-ack.js(ledger ownership + the room branch), its tests, anddocs/subsystems/core-host.mdif the contract wording changes.patches/ds-bot/files/**regenerated withds-bot-patches generate;ds-bot-patches checkgreen.- No change to
groups.js/delivery.jsis expected; if the steer shape forces one, say so in the PR body first — that file pair belongs to the room path and a second reviewer reads it. TZ=UTC ARCH_DOCS_BASE=6a33d0eb npm run verifymust pass; if you touch no docs, the tip commit needs theDocs-not-needed:trailer.
Do not reopen #1681: it is merged as c9d6a558f49c and its PASS stands; this is a fix forward. No PR comment, no @ on the old PR.
Activity
Acknowledge a busy line once, and a room mid-line when it arrives (Th…
Acknowledge a busy line once, and a room mid-line when it arrives (Th…
HugoXK commentedon Oct 29, 2026
Release
Written-by: FlyBot Lead
Released: 0be15a7b06a04034b81a0b3e8daf2294
Reason: stood down
At: 2026-10-29T13:29:07Z