Skip to content

core: the busy ack is composed only when the person's line is claimed, so a long tool wait shows no acknowledgement at all #1678

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: 8fe408e92fd449e0abe51858e45e0ae3
Branch: dev/3-busy-ack-arrival
Base: 11a4be7
At: 2026-10-29T11:06:58Z

What happens

When a person sends a line while a Bot is busy, the own chat is supposed to show a busy acknowledgement immediately (a … dot, then one short sentence), and the room row a dot too. On main 11a4be75 nothing appears at arrival: the sentence (and the model call that writes it) only happens once the person's line is claimed at the next step start — which can be many minutes after the line arrived.

Reason, same family as #1673 (which fixed steering-interrupt.js only): core/src/host/busy-ack.js subscribes to user/message, but DSH appends a person's line to the inbox as agent/inbox/spliced when it arrives (mutate, layout captured in the event) and writes user/message only at the next step's firstAttempt, i.e. when the line is taken out of the inbox. So during a long tool wait the ack listener sees nothing.

Measured lateness between arrival and claim on production session logs (arrival = agent/inbox/spliced with source.kind=user, claim = the same line's user/message):

  • 08964929 line 815 09:57:05.070Z → line 909 10:09:53.404Z = 768 s
  • dc0e6a03 lines 72/73 07:21:45.295Z / 07:23:30.416Z → line 205 07:29:49.427Z = 484 s / 379 s

Room rows are worse in a different way: roomBusyAcksView invents {text:"",at:0} for any running turn, so the row shows a dot at arrival and keeps it, but the sentence still waits for the claim.

core/src/host/busy-ack.js is blob fb1e743db83c… (9610 bytes) both on 1db1037a and on 11a4be75, so this is not something the last three merges introduced.

Reproduction

Root at main 11a4be75c5b6148d4a1b05fa308ecebe7e3a337f, Node ≥22, core/node_modules + dev/harness/node_modules installed (cd core && npm ci && npm run harness). The probe writes only to mkdtemp and removes it.

cd core
# save the probe below as p150-busy-ack-arrival.mjs, then:
node --import ./test/harness-resolve.mjs p150-busy-ack-arrival.mjs "$(cd .. && pwd)"; echo "exit=$?"
# exit 1 = no ack at arrival (defect present), exit 0 = fixed

Probe (real install() of store.js + busy-ack.js, real event shapes, no hand-written state):

// usage: node p150-busy-ack-arrival.mjs <repo root>   exit 1 = defect reproduced
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'

const tree = process.argv[2]
const load = p => import(pathToFileURL(join(tree, p)).href)
const { install: installStore } = await load('core/src/host/store.js')
const { install: installBusyAck } = await load('core/src/host/busy-ack.js')

let calls = 0
const llm = {
  async resolveModelInfo() { return { reasoning: { efforts: [{ id: 'off' }] } } },
  async *stream() {
    calls += 1
    yield { type: 'text-delta', index: 0, text: '收到,我先把手上这张图跑完再改这段。' }
    yield { type: 'finish', reason: { kind: 'stop' } }
  },
}

const dir = mkdtempSync(join(tmpdir(), 'p150-'))
const statePath = join(dir, 'state.json')
writeFileSync(statePath, JSON.stringify({
  version: 2, mainBotIds: ['a'],
  members: { 'owner-1': { id: 'owner-1', createdAt: 1, designatedChiefId: 'a' } },
  bots: {
    a: { id: 'a', name: 'Chief', sessionId: 'a', ownerId: 'owner-1', createdAt: 1 },
    b: { id: 'b', name: 'Writer', sessionId: 'b', ownerId: 'owner-1', createdAt: 2 },
  },
  rooms: { r1: { id: 'r1', name: 'Ops', members: ['a', 'b'], mode: 'admin', admin: 'b', named: true, sessions: { b: 'gb' }, log: [], seq: 0, seen: {} } },
  agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0,
}))
const listeners = new Map()
const rt = {
  ctx: {
    llm,
    logger: { warn() {}, info() {} },
    effect(execute) { const d = execute(); return () => d?.() },
    on(name, fn) { (listeners.get(name) ?? listeners.set(name, []).get(name)).push(fn) },
  },
  home: dir, statePath, autoRoomName: () => 'Room', retitleRoom: async () => {}, dropGroupSession: () => {}, warn: () => {},
}
installStore(rt)
await rt.load()
rt.live = new Map()
installBusyAck(rt)
const session = id => ({ id, requestHeader: () => ({ config: { provider: 'p', model: 'big' } }), deriveMessages: () => [] })
const emit = (id, type, data) => { for (const fn of listeners.get('session/event') ?? []) fn(session(id), { type, seq: 1, data }) }
const settle = () => new Promise(r => setImmediate(r))

const text = '先把周报发我,然后改一下封面那张图'

// 1) Own chat, a turn blocked in a tool: the person's line ARRIVES as a splice.
rt.live.set('a', { turn: 1, tool: 'bash' })
emit('a', 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [{ content: [{ type: 'text', text }], source: { kind: 'user' } }] })
await settle()
const atArrival = rt.busyAcksView()
console.log(`own chat: acks right after agent/inbox/spliced = ${JSON.stringify(atArrival)}  (expected one entry with text '' — the dots)`)
console.log(`own chat: model calls so far = ${calls}`)

// 2) The same line is CLAIMED at the next step start: the only event the ack listens to.
emit('a', 'user/message', { source: { kind: 'user' }, content: [{ type: 'text', text }] })
await settle()
console.log(`own chat: acks right after user/message (claim) = ${JSON.stringify(rt.busyAcksView())}`)
console.log(`own chat: model calls after claim = ${calls}`)

// 3) Room member: roomBusyAcksView invents a dot for any running turn, sentence still waits for the claim.
rt.live.set('gb', { turn: 1, tool: 'bash' })
const roomSay = { source: { kind: 'bot', form: 'relay', role: 'room', roomId: 'r1', exchangeId: 'x1', senderName: 'You', hop: 0 }, content: [{ type: 'text', text: `[Group chat "Ops" · new message from the user]\n${text}` }] }
emit('gb', 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [{ content: roomSay.content, source: { kind: 'user' } }] })
await settle()
console.log(`room member at arrival: roomBusyAcksView({b:'gb'}) = ${JSON.stringify(rt.roomBusyAcksView({ b: 'gb' }))}`)
emit('gb', 'user/message', { ...roomSay })
await settle()
await rt.busyAckReady('gb')
console.log(`room member after claim: roomBusyAcksView({b:'gb'}) = ${JSON.stringify(rt.roomBusyAcksView({ b: 'gb' }))}`)

// 4) The shipped fixture's shape: a bare user/message with the turn already running.
const test = readFileSync(join(tree, 'core/test/busy-ack.test.js'), 'utf8')
const bare = /const say = text => \(\{ type: 'user\/message'/.test(test)
const splices = (test.match(/agent\/inbox\/spliced/g) ?? []).length
console.log(`busy-ack.test.js: bare 'user/message' helper = ${bare}; 'agent/inbox/spliced' occurrences = ${splices}`)

rmSync(dir, { recursive: true, force: true })
const okArrival = Object.keys(atArrival).length > 0
console.log(`\n[acceptance] an ack exists the moment the line arrives (before any claim): ${okArrival} → expected true`)
process.exit(okArrival ? 0 : 1)

Observed on 11a4be75, 2026-10-29T11:31:15Z (exit 1):

own chat: acks right after agent/inbox/spliced = {}  (expected one entry with text '' — the dots)
own chat: model calls so far = 0
own chat: acks right after user/message (claim) = {"a":{"text":"收到,我先把手上这张图跑完再改这段。","at":1793273475974}}
own chat: model calls after claim = 1
room member at arrival: roomBusyAcksView({b:'gb'}) = {"b":{"text":"","at":0}}
room member after claim: roomBusyAcksView({b:'gb'}) = {"b":{"text":"收到,我先把手上这张图跑完再改这段。","at":1793273475979}}
busy-ack.test.js: bare 'user/message' helper = true; 'agent/inbox/spliced' occurrences = 0
[acceptance] an ack exists the moment the line arrives (before any claim): false → expected true

The shipped core/test/busy-ack.test.js is 14/14 green today because its helper hand-writes user/message as if that were arrival (:36); the file contains agent/inbox/spliced zero times, so the arrival path is untested.

Acceptance

All five must be observable with the probe's own output shape (busyAcksView() / roomBusyAcksView() / a model-call counter), each as its own test:

  1. Own chat, turn blocked in a tool: the line arrives as agent/inbox/spliced (inserted[].source.kind==='user') → an ack exists at that moment ({text:""} dot first, sentence filled ≤3 s), model calls = 1.
  2. The same line is then claimed as user/message → still 1 ack with the same text, and model calls stays 1 (no second call).
  3. No turn running (idle) → busyAcksView() = {}, model calls = 0 (unchanged behaviour). A line that lands in a running turn is acked whatever the wait so far — the shipped rule is sourceKind==='user' && turnRunning && ownChat with no duration threshold (busy-ack.js:50-52 on 11a4be75), so a 5 s wait and a 300 s wait behave the same. A target:'next-step' splice is a steer admitted at the running turn's next step and must not ack.
  4. Room member (room.sessions[b]='gb') waiting in a tool >60 s receives a room line → roomBusyAcksView({b:'gb'}) shows that row's ack and fills its sentence ≤3 s. (Reaching the visible row is #P-149's delivery side, not this issue's; assert the view here.)
  5. The arriving splice's source.kind is bot / schedule / time-context → busyAcksView() = {}, model calls = 0.

Scope and notes

  • One host file: core/src/host/busy-ack.js — add the agent/inbox/spliced branch (keep the user/message path as the claim-time fallback) and make the claim step not compose a second ack for a line that already has one.
  • The existing de-dupe key is the session only: acks.get(sessionId) (busy-ack.js:139) with a window now - existing.at < COALESCE_MS (:140, COALESCE_MS = 3000 at :18). A 3 s window cannot express "this line already has an ack" when the claim can be 768 s later — key on the line's identity from the splice payload (e.g. its source.exchangeId / seq), not on the timestamp window.
  • core/src/host/busy-ack.js is mirrored under patches/ds-bot/**: run ds-bot-patches generate and land the regenerated mirror in the same commit; check must print ok 10 patches apply cleanly in order and ok core/ equals ds-bot/ + patches/ds-bot/.
  • No client change expected: the dot and sentence already render from the existing views. If core/client.js is regenerated by the build, say so in the PR body.
  • docs/subsystems/core-host.md describes the busy ack; if it does not change, the tip commit must carry a Docs-not-needed: trailer (a trailer in the PR body alone fails arch-docs and turns tests-ok red before the suite runs — see #1675 / comment 6494515948).
  • Related but separate: this is the generation side; the room sentence not reaching the row is a delivery-side defect (P-149), and #1667 is what made the ack a model call at all.
  • Repro used here is git-free and touches no session logs; production numbers above come from reading the session log copies only.

Verification expected in the PR

  • Red first on 11a4be75 with the new tests and the old busy-ack.js (paste the failing test names/lines).
  • TZ=UTC ARCH_DOCS_BASE=11a4be75 npm run verify exit 0 with the per-suite counts.
  • Probe prints [acceptance] … true and exits 0 after the fix; acceptance 3 and 5 stay silent.

Activity

  1. HugoXK commented on Oct 29, 2026

    @HugoXK
    ContributorAuthor

    Acceptance 3 corrected (my wording, not a scope change)

    Written-by: FlyBot Lead

    The issue body said «the line arrives <60 s into a turn → no ack». That was wrong: the shipped rule on 11a4be75 is sourceKind==='user' && turnRunning && ownChat (core/src/host/busy-ack.js:50-52) with no duration threshold, so a line landing 5 s into a turn is acked exactly like one landing 300 s in.

    Item 3 now reads: idle → no ack, no call; a running turn → ack whatever the wait; a target:'next-step' splice is a steer and must not ack. #1681 already tests it that way.

  2. HugoXK commented on Oct 29, 2026

    @HugoXK
    ContributorAuthor

    Release

    Written-by: FlyBot Lead
    Released: 8fe408e92fd449e0abe51858e45e0ae3
    Reason: merged as c9d6a55 (#1681, PASS 6495763857 at 04f1c7b)
    At: 2026-10-29T12:05:05Z

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