Session TTL-close and retention GC sweep the whole shared session table, but their "do not touch this one" exemptions are member-local memory.
What is wrong
Two sweeps in sweepIdle read the shared session table with no owner predicate and then decide with in-process state that only the owning member has.
TTL close. daemon.ts:19616:
const closed = this.store.closeIdleSessions(
now, ttl,
(agentId, acpSessionId) => !this.sessionSdkQuiescent(agentId, acpSessionId)
)
closeIdleSessions (packages/daemon/src/store/local-store.ts:2096) selects across the whole table. The exemption is the background-task lease — this.sdkLease, an in-memory map fed by the Claude SDK lifecycle feed on the member running the host (daemon.ts:2015). On a member that does not hold the agent there is no lease entry, so sessionSdkQuiescent reports quiescent, the session is not exempt, and it is closed.
Retention GC. sweepSessionRetention (daemon.ts:19419) iterates store.listExpiredSessions(...) — again unfiltered — and gates on sessionRetentionActive (daemon.ts:19379), which is drainingAgents / inflight / pending / the SDK lease, all member-local, plus one shared check (sessionHasPendingInboxRows). It then runs removeSessionWorktree and store.deleteSession.
The failing scenario
Agent X is held by member B and is running a long background task: the turn has returned end_turn, the session row is idle, and only B's sdkLease says the work is still live. Member A's idle sweep runs, sees the row past agentIdleTimeoutMs, finds no lease of its own, and TTL-closes it. B's background job loses the session it was going to report into, and A additionally emits a phase: 'end' session-metadata snapshot (daemon.ts:19713) for a session it does not own — which is the same shared-outbox problem #1023 is about, seen from the producing side. This is a direct regression of the background-task lease under a pool: the lease was the fix for background jobs being idle-killed, and it only protects against the member that holds it.
The retention pass is the same shape with a longer fuse and a worse ending: member A can decide a session is inactive while B is mid-turn on it (inflight/pending are B's), delete the row, and write a purge receipt. On a --k8s member the worktree half is also wrong — removeSessionWorktree reaches the sandbox volume only through runsInSandbox / gitRunnerFor, which is false on a member with no channel for that agent, so it falls back to the member's own (ephemeral) filesystem while still deleting the shared row.
Smallest correct fix
Scope both sweeps to agents this member is responsible for: skip any candidate where this.dutyEnforced() && !this.duties.holdsAgent(row.agentId). That is the same predicate transportAgents() and syncAgentSchedules already use, it makes each member's local exemptions authoritative for exactly the rows it decides, and it removes the non-owner producer of session-metadata snapshots that #1023 has to absorb downstream.
Part of the "one daemon owns this agent for its lifetime" class tracked by #955.
The drain behind the retention sweep repeats #1023 on a second table
drainSessionPurges (daemon.ts:19497) reads store.listSessionPurges (local-store.ts:2321), which is likewise unscoped, and emits cp.emitSessionPurged({ agentId, … }) per agent group. On a failed group it does a bare return (daemon.ts:19560) rather than continue:
} catch (err) {
// Keep the receipts. The report is idempotent on the CP, so re-sending
// an already-applied batch after a lost ACK is safe.
this.log.warn(`retention: purge receipt report failed for agent ${agentId} (${formatErr(err)})`)
return
}
So one non-held agent's group — rejected by the CP because this member is not authorized for it — head-of-line blocks every later group's receipts from this member, on every sweep, indefinitely. That is the #1023 pattern (a locally-raised scope refusal retried forever over a shared outbox) on the session-purge table instead of the session-metadata one, and it is fixed by the same scoping plus a continue.
Session TTL-close and retention GC sweep the whole shared session table, but their "do not touch this one" exemptions are member-local memory.
What is wrong
Two sweeps in
sweepIdleread the shared session table with no owner predicate and then decide with in-process state that only the owning member has.TTL close.
daemon.ts:19616:closeIdleSessions(packages/daemon/src/store/local-store.ts:2096) selects across the whole table. The exemption is the background-task lease —this.sdkLease, an in-memory map fed by the Claude SDK lifecycle feed on the member running the host (daemon.ts:2015). On a member that does not hold the agent there is no lease entry, sosessionSdkQuiescentreports quiescent, the session is not exempt, and it is closed.Retention GC.
sweepSessionRetention(daemon.ts:19419) iteratesstore.listExpiredSessions(...)— again unfiltered — and gates onsessionRetentionActive(daemon.ts:19379), which isdrainingAgents/inflight/pending/ the SDK lease, all member-local, plus one shared check (sessionHasPendingInboxRows). It then runsremoveSessionWorktreeandstore.deleteSession.The failing scenario
Agent X is held by member B and is running a long background task: the turn has returned
end_turn, the session row isidle, and only B'ssdkLeasesays the work is still live. Member A's idle sweep runs, sees the row pastagentIdleTimeoutMs, finds no lease of its own, and TTL-closes it. B's background job loses the session it was going to report into, and A additionally emits aphase: 'end'session-metadata snapshot (daemon.ts:19713) for a session it does not own — which is the same shared-outbox problem #1023 is about, seen from the producing side. This is a direct regression of the background-task lease under a pool: the lease was the fix for background jobs being idle-killed, and it only protects against the member that holds it.The retention pass is the same shape with a longer fuse and a worse ending: member A can decide a session is inactive while B is mid-turn on it (
inflight/pendingare B's), delete the row, and write a purge receipt. On a--k8smember the worktree half is also wrong —removeSessionWorktreereaches the sandbox volume only throughrunsInSandbox/gitRunnerFor, which is false on a member with no channel for that agent, so it falls back to the member's own (ephemeral) filesystem while still deleting the shared row.Smallest correct fix
Scope both sweeps to agents this member is responsible for: skip any candidate where
this.dutyEnforced() && !this.duties.holdsAgent(row.agentId). That is the same predicatetransportAgents()andsyncAgentSchedulesalready use, it makes each member's local exemptions authoritative for exactly the rows it decides, and it removes the non-owner producer of session-metadata snapshots that #1023 has to absorb downstream.Part of the "one daemon owns this agent for its lifetime" class tracked by #955.
The drain behind the retention sweep repeats #1023 on a second table
drainSessionPurges(daemon.ts:19497) readsstore.listSessionPurges(local-store.ts:2321), which is likewise unscoped, and emitscp.emitSessionPurged({ agentId, … })per agent group. On a failed group it does a barereturn(daemon.ts:19560) rather thancontinue:So one non-held agent's group — rejected by the CP because this member is not authorized for it — head-of-line blocks every later group's receipts from this member, on every sweep, indefinitely. That is the #1023 pattern (a locally-raised scope refusal retried forever over a shared outbox) on the session-purge table instead of the session-metadata one, and it is fixed by the same scoping plus a
continue.