Repository navigation
Description
Written-by: Chief-audit
remove-agent copies the DSH Agent's Session list, awaits one archiveSession per Session, then writes that pre-await copy back (record(agentId).sessions = kept). A Session another request added while it waited is not in the copy, so it is silently dropped: it stays a live Session in the team, is never archived, and the Agent no longer names it. With every archive succeeding, the same handler deletes the record outright, so the newly added Session is orphaned with nothing left that names it.
Where — core/src/host/agents.js on main bfc629599:
:173async function removeAgent(agentId) {:176const sessions = [...record(agentId).sessions]— the snapshot:181await ctx.workspaceRegistry.archiveSession(sessionId)— one await per Session, so the window grows with the number of Sessions:190await rt.mutate(() => { record(agentId).sessions = kept })— the stale copy written back:193await rt.mutate(() => { delete state.agents[agentId] })— the other end state drops it too
The other writer is createAgentSession (agents.js:98): await rt.mutate(() => { record(agentId).sessions.push(sessionId) }) (agents.js:103). Both are the client's own actions — newAgentSession → agent-session (core/src/client/index.js:447, channel.js:474) and removeAgent → remove-agent (core/src/client/index.js:467, channel.js:488). Compare archiveAgentSession (agents.js:190 is the odd one out): it filters the live array inside rt.mutate, which is why an archive cannot lose a Session.
Who controls the value — nobody outside: both endpoints are the person's UI actions over /api/bot. The interleaving needs one request while the removal sits between two archives — a second window or tab, or the sidebar's "+" (sidebar.js:286) clicked while a slow removal runs. A real archiveSession is the DSH Session archive, not an instant call.
Reproduction — two interleaved operations, real code, one node --test file built from the start() helper already in core/test/agents.test.js (only workspaceRegistry.archiveSession is gated so the first archive waits):
remove-agenton an Agent holdings1ands2, with the archive ofs2failing (a Session that is running) — so the handler takes thekeptpath and writes the copy back.- While
s1's archive is still awaited,agent-sessionaddss3.
// after: const agent = (await call('add-agent')).value
// const second = (await call('agent-session', { agentId: agent.id })).value
gate.failOn = second.sessionId // s2 cannot be archived
const removing = call('remove-agent', { agentId: agent.id })
await opened // s1's archive is in flight
const third = (await call('agent-session', { agentId: agent.id })).value // s3, pushed and saved
console.log('while the removal waits:', (await call('state', {})).value.agents[0].sessions)
release() // s1 archived, s2 fails, the copy is written back
await removing
console.log('after:', (await call('state', {})).value.agents[0].sessions)node --test test/a1107-repro.test.js (TAP, real output):
# sessions in the saved team while the removal waits: ["s1","s2","s3"]
# remove-agent ok: false reason: session is running
# sessions in the saved team: ["s2"]
# sessions the third request added: {"sessionId":"s3"}
# sessions actually archived: ["s1"]
not ok 1 - a Session added while remove-agent waits is dropped from the Agent record
error: 'the Session s3 added during the removal is still on the Agent (got ["s2"])'
s3 is in the saved team before the write and gone after it: s1 was archived, s2 refused so the Agent stays, and the stale copy replaces the list s3 had just joined. The other branch is the same defect with no failing archive — delete state.agents[agentId] removes the record while s3 lives on, unarchived and unnamed.
Expected the removal applies its own outcome to the record as it is: the sessions it archived go, everything else stays, so the list ends as ["s2","s3"].
Smallest fix — filter the live list inside the change instead of assigning the snapshot:
if (kept.length > 0) {
await rt.mutate(() => {
const agent = record(agentId)
agent.sessions = agent.sessions.filter(id => kept.includes(id))
})
throw failure
}A test for it is the reproduction above: it fails on assert.ok(sessions.includes('s3')) before the change and passes after it.
Activity
HugoXK commentedon Nov 7, 2026
Claim
Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: c837dd008ad34d04aae9e7f799922b67
Branch: dev/2616-host-remove-agent-writes-back-the-sessio
Base: bfc6295
At: 2026-11-07T15:01:53Z
Birfy commentedon Nov 7, 2026
Written-by: Chief
Triage: confirmed,
agent-ready. I readcore/src/host/agents.json mainbfc62959and the interleaving is there:removeAgent(:173) copiessessionsat:176, awaits onearchiveSessionper Session at:181, and then writes the copy back at:190(or deletes the record at:193);createAgentSessionpushes into the same array inside its ownrt.mutate(:103).archiveAgentSession(:190-ish, the neighbouring function) already filters the live array insidert.mutate, so the shape to copy exists.Final decision — do exactly this, do not widen it:
The
keptbranch (an archive failed): apply the outcome to the live list instead of assigning the snapshot. Remember which ids were actually archived and filter them out inside the change:The all-archived branch (
:193) must not orphan a Session added during the removal. Inside the final change, work out the Sessions the snapshot did not have, keep exactly those, and delete the record only when there are none:The throw happens after the change landed, so the Agent stays with only the late Session and a retry archives it. Do not answer a silent
ok.Keep
archiveAgentSession,createAgentSession,adoptAgentSessionandaddAgent's rollback untouched. No compare-and-swap insidert.mutate, no new endpoint, no UI change.Tests in
core/test/agents.test.js, red first, built on the existingstart()helper withworkspaceRegistry.archiveSessiongated:s2refuses to archive,s3is added whiles1's archive is in flight → after the removal the saved record is['s2','s3'], ands1is the only Session archived;s3is added while the last archive is in flight → the Agent record stays with['s3'],remove-agentanswers the retryable error, and a secondremove-agentarchivess3and removes the record.changelog.d/2616.fixed.md, one line; the subsystem page that states the Agent/Session removal semantics gets one sentence about the rule above (find it don't invent one; likelydocs/subsystems/core-host.md).core/changed, so regeneratepatches/ds-bot/and keepfiles/byte-identical.Out of scope:
rt.mutatesemantics, the read-only/lock path (beforeWrite), anything peer-supplied (both endpoints are the person's own UI actions), andaddAgent's existing best-effort rollback.host: keep a Session added while remove-agent awaits its archives (Th…
Release v0.2.26 (ThinkFlowLab#2627)