Skip to content

core: the busy ack says one line twice (its id ledger dies at turn/end) and never speaks for a room mid-line, which arrives as a next-step steer #1684

Closed

Description

@HugoXK

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/spliced id c59bab75 at 4836 (target next-turn, source kind: 'user') → ack call 1 at 4854
  • tool/result 20233, turn/end (completed) 20426, turn/start 20432
  • the claim user/message with 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 at is 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:

  • :162 the entry that holds a line id is { text: '', at, id, lines: new Set() } — the id ledger lives inside the ack entry.
  • :153 the dedupe is if (lineId !== undefined && existing.lines.has(lineId)) return existing — it can only fire while that entry still exists.
  • :209 if (event.type === 'turn/end') clear(id) — and clear is :172 acks.delete(sessionId), so the ledger goes with it. :212-216 clears on agent/status === 'idle' too.
  • :182-192 the arrival branch requires event.data?.target === 'next-turn'; the room condition (room && turnRunning(id)) can therefore only be reached for a next-turn room splice, which the busy-member path never produces (groups.js:557 steers).
  • :194-203 the claim branch is fine on its own; it just starts a fresh entry because :209 already 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

  1. Real order reproduced red first: agent/inbox/spliced (id X, next-turn, kind: 'user') → tool/result → turn/end → turn/start → user/message with id X. Before the fix: 2 ack calls (assert on the call count, not only on the sentences). After: 1 call, and busyAcksView holds exactly one entry for that session whose text is non-empty.
  2. 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.
  3. 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.
  4. Room mid-line: with the real relay shape (groups.js:557 → delivery.js:228, role: 'room', no senderSessionId, 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).
  5. next-step handling does not double-ack: the same steered line, when it is later admitted as user/message, must not add a second call (it may reuse the same ledger).
  6. Idle and non-user sources are unchanged: no turn running → 0 calls; kind: 'bot'/schedule/time-context → 0 calls.
  7. The new tests must use the real event order above (a turn/end between splice and claim); the synthetic no-turn/end shape 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, and docs/subsystems/core-host.md if the contract wording changes.
  • patches/ds-bot/files/** regenerated with ds-bot-patches generate; ds-bot-patches check green.
  • No change to groups.js/delivery.js is 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 verify must pass; if you touch no docs, the tip commit needs the Docs-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

  1. HugoXK commented on Oct 29, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: 0be15a7b06a04034b81a0b3e8daf2294
    Reason: stood down
    At: 2026-10-29T13:29:07Z

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions