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: 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):
08964929line 81509:57:05.070Z→ line 90910:09:53.404Z= 768 sdc0e6a03lines 72/7307:21:45.295Z/07:23:30.416Z→ line 20507: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 = fixedProbe (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:
- 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. - The same line is then claimed as
user/message→ still 1 ack with the same text, andmodel callsstays 1 (no second call). - 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 issourceKind==='user' && turnRunning && ownChatwith no duration threshold (busy-ack.js:50-52on11a4be75), so a 5 s wait and a 300 s wait behave the same. Atarget:'next-step'splice is a steer admitted at the running turn's next step and must not ack. - 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.) - The arriving splice's
source.kindisbot/schedule/time-context→busyAcksView() = {},model calls = 0.
Scope and notes
- One host file:
core/src/host/busy-ack.js— add theagent/inbox/splicedbranch (keep theuser/messagepath 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 windownow - existing.at < COALESCE_MS(:140,COALESCE_MS = 3000at: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. itssource.exchangeId/ seq), not on the timestamp window. core/src/host/busy-ack.jsis mirrored underpatches/ds-bot/**: runds-bot-patches generateand land the regenerated mirror in the same commit;checkmust printok 10 patches apply cleanly in orderandok core/ equals ds-bot/ + patches/ds-bot/.- No client change expected: the dot and sentence already render from the existing views. If
core/client.jsis regenerated by the build, say so in the PR body. docs/subsystems/core-host.mddescribes the busy ack; if it does not change, the tip commit must carry aDocs-not-needed:trailer (a trailer in the PR body alone failsarch-docsand turnstests-okred 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
#1667is 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
11a4be75with the new tests and the oldbusy-ack.js(paste the failing test names/lines). TZ=UTC ARCH_DOCS_BASE=11a4be75 npm run verifyexit 0 with the per-suite counts.- Probe prints
[acceptance] … trueand exits 0 after the fix; acceptance 3 and 5 stay silent.
Activity
HugoXK commentedon Oct 29, 2026
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
11a4be75issourceKind==='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.#1681already tests it that way.HugoXK commentedon Oct 29, 2026
Release
Written-by: FlyBot Lead
Released: 8fe408e92fd449e0abe51858e45e0ae3
Reason: merged as c9d6a55 (#1681, PASS 6495763857 at 04f1c7b)
At: 2026-10-29T12:05:05Z
fix(host): do not acknowledge the message that starts a turn