# Session Manager Module ## Overview Maps thread keys to LLMProvider instances (`session.py`). Each thread gets its own kiro-cli session with idle expiry, context compaction, circuit breaker, per-session semaphore, and persistent background session. Chat sessions are served from the warm pool when eligible (default pool agent, default cwd, no resume mapping); otherwise they cold-start on first message via `get_or_create()`. Successful native ACP resume suppresses replay from both disk and the dashboard's live slot window. The runner honors the actual provider client's resumed state as well as the SessionManager result. A real cold start uses the canonical merged replay; an explicit reset suppresses that replay instead of silently reloading a fallback. The current request is delivered once and is not replayed as historical input. This includes cron, recovery and user-replay injections; queue drain supplies the exact appended row to the runner. `running` gates turn admission and destructive history edits; `turn_running` reports execution. `running` also reads `_turn_admission_reserved`, which the dashboard send handler sets for a person's own send between admitting the row and publishing `slot.task`, the window that holds the automatic Slack link (`chat_slack.maybe_auto_link_slack`, at most `AUTO_LINK_HOLD_SECS`), so a second send arriving in that window takes the queue path instead of dispatching a second turn; the handler's busy branch reads the same flag, and a `finally` clears it on every path out of the window. A Stop pressed in that window has no task to cancel, so the handler reads `_stop_generation` across the hold and does not dispatch the stopped turn; a send queued behind it in that window starts then, as it would at the end of a stopped turn. `test_slot_running_readers.py::test_running_and_turn_running_slot_readers_are_enumerated` walks every dashboard symbol and pins each direct or shared-helper reader by `(module, symbol)` to the predicate it uses; the same census enumerates every non-null `slot.task` publisher so a new unguarded admission site fails the test. Continue and Project requests retain the slot object they originally authorized. They reject a same-name replacement after lock acquisition and each pre-commit suspension: Continue's child probe, and Project's body read, workspace validation and recent-project save. A refused save rolls back only its own project token and cannot arm a deferred reset or eager spawn for the replacement. Eager spawn loads its agent-binding configuration in a worker thread. Slot and session state stay on the loop; existing binding and ownership checks before allocation discard work superseded while configuration or resolution awaits. Configuration migrations retain their fresh-document delta persistence and writer-lock ordering, including when this speculative load is the first load. ## Dashboard app launch intents The App SDK's `slotKey` selects an existing dashboard slot through ordinary activation on both cold entry and navigation within an already-mounted chat. The session controller claims the target intent before URL synchronization and releases its message only after a fulfilled `switchSlot`. Slow activation does not expire a claimed message; failure shows the existing session-open error with the unsent message available to copy and never sends to a fallback. A routed chat waits for that exact target before using the message; an embedded chat never consumes the dashboard intent. `window.__mc_chat_launch` is claimed once: the session controller owns explicit targets and fresh drafts; `ChatPage` owns untargeted automatic sends (`useAppLaunchIntake`, `website/src/pages/chat/page/launchIntake.ts`). Unclaimed intents expire after ten seconds. A newer intent supersedes a pending target by ref identity, so its later completion cannot release the older message. `autoSend: false` seeds an unsent draft, appending to existing text when the target already has a draft. Without a target, the existing new-session controller creates one session and stages the draft before navigation, retaining it on creation failure for retry. Agent selection applies to new sessions only. These options do not attach app task metadata or change backend session authorization. App auto-send uses only the supplied text, leaving staged files, pasted content, knowledge and session references untouched. Existing composer sends and legacy URL auto-send retain their prior behavior. Refused app turns and failed app session creation keep their text in the page-level copyable error notice, not an unrelated draft. Recovery adds no user row to a busy slot. That notice survives slot changes but is not persisted across page unmounts. Creation failure does not re-arm a new-session intent for the next manual send. ## Dashboard chat state The split-session picker's recency tie-break and artifact companion selection compare `last_activity_ts` as an instant, preserving sub-millisecond transcript precision. Needs-input and running sessions retain their picker priority. Missing or unparseable activity sorts last. Legacy naive timestamps resolve in the viewer's timezone; a viewer in another zone cannot recover the writer's timezone from this payload. The dashboard holds every open session's chat state in one Redux slice, `chat`. `website/src/store/chatSlice.ts` is its only `createSlice` call and the one path consumers import it through: every action creator, thunk, selector, constant and type resolves there, and every action type stays `chat/`. The facade composes owners under `website/src/store/chat/`, and the dependency runs one way: no owner imports the facade, and no module in `website/src`, `website/integration` or `website/capture` outside `website/src/store` imports an owner (`website/src/store/chatSlice.ownership.test.ts`). | Owner | What it owns for a slot | |---|---| | `website/src/store/chat/state.ts` | `ChatState`, its initial value, and the persisted `mc-activity-open:` key family | | `website/src/store/chat/wire.ts` | the trust boundary: the `__proto__` / `constructor` / `prototype` key guards and the slot-detail read with its normalization | | `website/src/store/chat/runState.ts` | the active-slot handover, running / stopping state, the turn-start epoch, and the navigation MRU. A reconnect warm promotes a background pane to streaming only when the server reports it running and no ordered per-slot frame raced the warm (the receipt tick, `runWarmSeq`, and the `enterActiveSlot` hand-back) | | `website/src/store/chat/slotSwitch.ts` | `switchSlot`: the bounded slot-detail read, the atomic handover, the unwind when the target is gone, and the pane notice a failed gesture raises | | `website/src/store/chat/slotRefresh.ts` | `refreshSlot` and `warmSlotCache`, the re-reads that do not switch | | `website/src/store/chat/lifecycle.ts` | `createSlot`, `forkSlot`, `resumeFromHistory`, `fetchHistory` and `deleteHistorySession`, with the notices a failed attempt leaves | | `website/src/store/chat/slotResidue.ts` | teardown of slots that left the authoritative list, and the one list of per-slot maps every teardown clears | | `website/src/store/chat/activity.ts` | the activity panel and live tool log: the slot's tab and persisted open state, the inline tool-focus signal, and the tool-call / approval / tool-result frames that fill the log | | `website/src/store/chat/composerCards.ts` | the cards above the composer, one per slot: the question card, follow-up suggestions and the folder suggestion | | `website/src/store/chat/automations.ts` | monitor and goal-loop automations per slot, reconciled from the REST snapshots and upserted from live frames | | `website/src/store/chat/workflows.ts` | live dynamic-workflow runs, their reconcile against the authoritative run list, and the sidebar's per-session activity selectors | | `website/src/store/chat/selectors.ts` | slot-scoped reads, the composer's busy rule, the Continue predicate that mirrors `_has_conversation` in `dashboard/chat_handlers.py`, and the Resume predicate that mirrors `is_turn_interrupted` in `dashboard/state.py` | The facade keeps three constructs that a read-only check reads from this file: the live `chat_message` frame reducer with the question-retiring role set and the chunk-gap marker (`test/test_slot_needs_input_status.py` and the i18n ledger), `deleteSlot` with the one `dispatch(switchSlot(` site the call-site oracle counts here, and `loadOlderMessages` with the slot capture `chatPins.test.tsx` reads. `requestStop` stays too, because it dispatches this slice's own actions. The transcript-window and history-cache owners are listed in [history](history.md#the-dashboard-transcript-window-frontend); Side Chat, sub-agent and MCP App state in [side](side.md), [subagent](subagent.md) and [mcp-apps](mcp-apps.md). ## Implementation Boundaries `SessionManager` remains the compatibility facade in `session.py`; callers keep using its existing public, import, and monkeypatch seams. Mutable policy and state are composed behind that facade: - `session_allocation.py` — live registry, key folding, semaphore leases, cold-start allocation, claims, and companion runtimes - `session_pool.py` — warm-provider spawn, inventory, claim, health, and discard - `session_background.py` — persistent and multiplexed background runtimes - `session_compaction.py` — context gates, compaction execution, verdicts, cooldowns, and guarded escalation - `session_lifecycle.py` — refresh/reload, reset/remove/destroy/discard, identity retirement, stop, drain, and close ordering - `session_cleanup.py` — cleanup-task state, watchdog hooks, idle/RSS/stuck-turn policy, and process/filesystem sweeps `POST /api/chat/slots` refuses to create a slot bound to a remote crew: a body carrying `instance_id` or `adopt_remote_slot` is refused with `400 remote_create_retired`. A session on a connected crew is created on that crew and opened here through its window. A slot already on disk with `executor: "remote"` still loads, as a read-only relay archive: a send, regenerate, rewind, continue or checklist write, and a change of agent, model, effort or workspace, answer `409 relay_archive_read_only` (`dashboard/relay_archive.py`). Cross-boundary calls that were observable on `SessionManager` route back through the facade, and patchable module dependencies are resolved through injected call-time functions. Persistence remains owned by the existing `SessionMap` contract; this decomposition does not change its stored format. The owner protocols and forwarding dependency adapters are transitional compatibility boundaries, not extension points. New internal behavior belongs on the service that owns its state; widen a protocol or adapter only when an existing facade/import/monkeypatch seam requires it. Individual adapters may be retired in follow-up changes after repository-wide callers and characterization tests have moved off the corresponding legacy seam. ## Agent selection provenance Each session owns one canonical `execution_context` in its ordinary metadata. The frozen record binds `member_id` (or explicit Global/V1), `MemoryStoreRef`, member/template namespace, provider template, privacy mode and app attribution. A configured member has an immutable persisted ID independent of its editable label. Templates, projects and display names cannot select member memory. Discovery of a same-named member cannot reinterpret an existing template session. Resolved dispatch bindings use the canonical `default` name for Global memory, including when restored from a captured execution record. Equivalent aliases therefore compare equally without changing the selected conversation identity. Admission captures the record before asynchronous work and passes it directly to provider allocation and prompt construction. Automatic selection publication compares the captured record; an explicit concurrent choice wins over delayed preparation. Rollback compares only the record this operation published, including live restricted-session records. Slot/session locks, drained cancellation, ordinary owner/app permissions and governance remain in force. A malformed or missing member carrier reports memory unavailable and never chooses Global. Existing ordinary V1 sessions retain their V1 behavior; old V2 grants are not migrated or used as a second authority. One record shape is backfilled instead of refused: a persistent session written before the field existed (0.7.0.5), carrying `agent` and a `memory_store` that names a declared V2 store but no `execution_context`. `read_session_execution` derives the carrier from the store's `owner_member_id` only when that id names exactly one configured member, that member resolves to the same store, and the record's `agent` names that member by alias or id; it then writes the carrier into the record with a compare-and-set against the exact legacy shape it read, so every later read decodes it like any other session. The derived identity is never vouched -- the store came from the session's own record -- so the session stands where a member session stands after a restart. A record with no `agent`, an `agent` naming anyone else, an explicit template pick, a store the start-of-process migration could not attribute, a `member_id` marker, or a restricted mode is left unchanged and still refused, and the refusal names the remedy that works for that store: open a new chat with the same member (or archive this one) when the store has an attributed owner, or the legacy store repair `kirocrew doctor` names when the start-of-process migration could not attribute it. A successful backfill logs one INFO line naming the session. Persistent sessions serialize this record in their existing owner metadata. Incognito and Temporary sessions keep it in live session state only. Their TRANSCRIPT is persisted like any other session's -- the user reopens an incognito or temporary chat from History, before or after a restart -- and what the mode withholds is everything derived FROM it: consolidation, lessons, memory injection, the session summary, workflow and task snapshots. The metadata line is the file's privacy contract, and both slot save and turn-start binding treat it as a ratchet. When a writer tightens a line held by a live slot, the slot follows that mode on the event loop and its restricted-key marker is re-derived, so export, summary, memory injection, and learning gates observe the same restriction. Transcript-derived durable and egress publication then revalidates the chained live lines through `ConversationLog.publication_hold`. Export and transfer pass the exact chain returned with their assembled rows, so any membership change before publication is retryable. Export commits its synchronous response under the hold, while transfer releases it immediately before its awaited tunnel send. The network transmit is the accepted residual window; no threading lock crosses an await. The binder folds a carrier-less line's canonical on-disk mode into a new execution before choosing its publication branch, so a persistent same-key replacement takes the live-only restricted branch and cannot write an owner store onto that line. When a live carrier already exists, the turn-start read-back reads the metadata line off-loop, folds its mode into that carrier, and republishes only the stricter carrier before memory context is built; an unreadable line is a transient read failure, not a mode -- the fold is withheld for that turn and the next turn's read-back tries again, since every mode the fold publishes is a one-way ratchet no path can widen -- while an absent line adds no mode. The slot save likewise records the STRICTEST mode it can see (the slot's own, or the live carrier's when the two disagree for a moment), never a looser one. Incognito may read memory; Temporary does not. Both refuse learned-memory writes. Children inherit the strictest admitted mode, even after a parent closes; replacing a parent cannot broaden their policy. If an existing persistent session becomes restricted, its existing owner record atomically tightens only retention metadata, and restart cannot recover a weaker mode. A new restricted session does not create a durable owner record, and its metadata line names NO `memory_store`: with no carrier on the line, a store name would read back as the legacy owner claim the identity backfill refuses for a restricted mode, and the restart would refuse the chat. Left out, the restart reads the session as unbound and the first turn re-selects the member from `agent` under the retained mode -- the same live-only carrier the session ran under before the restart. Because `agent` is a mutable alias, a DM re-selection must prove the chat still runs as the member it ran before: a DM slot KEY encodes the member's immutable id (the metadata line is agent-writable and is never an identity source), so a DM re-selection whose resolved id differs from that encoded id is refused -- the thread belongs to a specific member and the alias now names another; the member's memory stays intact and a new conversation binds it cleanly. `agent_kind` stays on the line: it is a display fact, not an owner claim. Tab close and idle archival snapshot the live restricted identity before yielding; cleanup does not read durable session metadata. Tab close retires its nudge loop before other asynchronous work. After the last consumer and provider stop, cleanup releases only the captured identity, preserving any replacement session carrier. ## Private member session ownership The section name is retained for existing documentation links. Member memory is application routing, not a confidentiality boundary against arbitrary code under the same OS user. There is no separate protected session/run grant, memory PID ancestry database, member HMAC capability, namespace mount or Seatbelt memory rule. Ordinary transport authentication, owner/app permissions, audit integrity, credential redaction and the host sandbox remain independent requirements. The member and store in an admitted session stay fixed. An explicit provider switch may change a template only through the session selection path; it cannot select another member store. A new member choice opens a new conversation where required by the dashboard ownership contract. Restoring history restores its canonical record, rather than resolving the latest display alias or project. Cron, workflow and subagent owners carry the same record in their own durable state so they do not depend on the lifetime of an originating chat. Permanent rules, briefing and profile documents are read directly using stable member identity. They remain available when the learned database is unavailable. Normal member prompts do not inject learned/core rows; explicit recall reports an unavailable or mismatched database without falling back to Global. Essential-context receipts belong to the serving provider, not a shared builder or logical key. Inner client, native session ID and process-instance identity prevent replacement providers from inheriting receipts. Compaction invalidates receipts and late pre-compaction completions cannot restore them. Fresh/resumed member lifecycle rules still control reinjection. Member prompt documents are prepared before process launch; a warm runtime prepared without them is bypassed. ## Member capability generations Enrolled members prepare capabilities only when allocating a new runtime. `session_capabilities.prepare_runtime` reconciles ordinary Parent updates and verifies the saved materialization off-loop before provider construction. It passes the immutable template explicitly while preserving the canonical member, member memory binding, history key, caller model and approval policy. An explicit or resumed cwd wins; otherwise the member's configured workspace is used. A cwd that disagrees with the saved Parent identity refuses startup. When no caller model is supplied, allocation resolves the member's model pin by its canonical alias, including members that have not enrolled capabilities. The id it hands the provider is stamped on the registered session, because the allocation returns only the provider and its first-turn observation: a caller recording what the session was asked to run reads that stamp (`SessionManager.allocation_requested_model`) rather than resolving a second time, so the id sent and the id recorded are one value. An empty stamp means the allocation resolved nothing, or that a registration site which resolves no models made the session. Enrolled allocations bypass warm and shared processes. Full-spec loading is supported by the dedicated Kiro backend; other harnesses refuse explicitly rather than falling back to the default agent. A successful mode handshake, fresh process instance, live session id, and post-start saved-byte/ownership/governance checks are all required before `_Session.loaded_capabilities` is stamped. MCP hot reload is not evidence that prompt, resources and the rest of the spec were loaded. The applied view also checks that each enabled MCP connection in that saved version has reported ready through the provider's own MCP report. Missing reports remain unverified, authentication requests remain pending, and initialization failures or unresolved tool refs report failure. Later ready reports can clear that state without restarting the conversation; raw provider errors are not included in capability status responses. `SessionManager.capability_runtime_view(member, saved_revision)` delegates to `SessionAllocationService`, which projects its owned `SessionRegistryState` on the event loop through `session_capabilities.runtime_view`. The projection reads live occupants and failed allocations from that same state and returns fresh response rows, never mutable registry dictionaries. Dashboard handlers do not access the manager's private registries. Old live sessions report pending and keep their current turn and context; saving never resets them or requests history replay. A changed process, handle, active template or governance generation removes the applied claim. Replacing a provider clears its stamp. Failed starts leave a bounded retryable diagnostic; a successful retry replaces it with the real session. The owner capabilities GET and PUT handlers call this helper on the event loop for the saved revision. Preview never claims runtime adoption. A failed saved-byte or source validation remains failed even if an older provider is still alive; persistence alone cannot claim application. ## Background Session `BACKGROUND_KEY = "_bg"` is a persistent shared session for lightweight background work. It is: - **Created on startup** by `start_pool()` alongside the warm pool. Before the provider factory runs, `_ensure_background` awaits `agent_discovery.warm_agent_specs()` (one `parsed_agent_specs` parse on `mc-discovery`, never raises) so the factory's synchronous, on-loop model lookup finds a warm snapshot and `_bg` is created on its agent's own pin rather than on `agent.model`; the lookup itself touches no file on the loop (see `_resolve_named_agent_model` in `config.md`) - **Never expired** by idle cleanup (`_expire_idle` skips it) - **Serialized** by the per-session semaphore (one background task at a time) — applies to the **non-kiro** `_bg` path only; see "Multiplexed _bg runtime" - **Shared by**: the `llm_helpers` background helpers and TaskRunner lesson extraction (NOT cron — see below). The heartbeat runs on its own persistent key, `HEARTBEAT_KEY = "_hb"` (also in `_PERSISTENT_KEYS`), not on `_bg`. This eliminates the cost of spawning/tearing down a kiro-cli process for every background task. Background tasks acquire the semaphore, do their work, and release — the process stays warm. ### Context Overflow Protection `recycle_background()` is called after every background task completes. It checks context usage and **recycles** (kill + fresh spawn) the session if needed — no compaction, since background tasks are stateless: - A turn refused as too long for the model's window → recycle, whatever percentage is reported, on the shared-conversation path only (backends outside `_bg_runtime_backends()`). After a refusal the reported percentage is still the last successful turn's, so the threshold alone kept a conversation that every later turn was refused on until the gateway restarted. The verdict reaches `recycle_background()` two ways: `run_bg_oneliner()` passes `context_overflowed=True` when its own turn raised it, and `stream_and_collect()` leaves it on the provider (`mark_context_overflowed`) because its callers, such as history consolidation, may swallow the error before `background_turn()`'s recycle runs. `llm_helpers.is_context_overflow_error()` is the one predicate: the classifier's `context_overflow` tag, or the backends' "Prompt is too long" / "context window overflowed" / "maximum context length" text, since the claude adapter puts its refusal in the JSON-RPC `message` the classifier does not read. Runtime-capable backends keep the criteria below unchanged. - At ≥ 70% context → recycle (same threshold as chat's default compaction) - A reported 0% that the provider flags as *unknown* (`context_usage_unknown` — the backend compacted in place) → recycle - After 40 prompts (`_BG_BLIND_RECYCLE_PROMPTS`) → recycle (blind backstop). This backstop is **not** gated on the reported percentage: background turns are tiny text prompts that never approach 70%, so keying it on "the backend reports no metadata" retired it permanently as soon as any real percentage was read, leaving the provider with no lifetime bound for the whole gateway uptime. `recycle_background()` counts the turn itself (`check_context_usage` is a chat-turn hook and never advances `_bg`), and the log names the backstop rather than the percentage that did not trigger it. - Process-tree RSS at or over `session.watchdog_rss_max_mb` → recycle. When that knob is 0 (its default, which turns the chat-session sweep off) the ceiling is `BACKGROUND_RSS_FALLBACK_MB` (1536 MiB), so this runtime stays bounded either way. - Below thresholds → no-op (session stays warm) Callers: heartbeat callback, taskrunner lesson extraction, `background_turn()`, and `run_bg_oneliner()` whenever its handle is a `_ProviderBgSession` (marked `shares_background_conversation`). That handle's `destroy()` only releases the turn semaphore, so without this recycle one-liner traffic (titles, nav labels, folder icons, summaries) would grow the shared conversation unboundedly, with no context criterion ever evaluated. A runtime handle's session is ephemeral and carries no marker, so it is not followed by a recycle. ### Multiplexed _bg runtime `get_bg_session()` acquires a `_bg` handle, dispatching by `agent.acp_backend` and returning `AcpSessionHandle | _ProviderBgSession`. Dispatch is via `_bg_backend_supports_runtime()` — positive membership in `_bg_runtime_backends()` — the intersection of `ACP_BACKENDS_ACP_RUNTIME`, `ACP_BACKENDS_SESSION_EVICTION` and `selectable_backends()` — never an inequality (harness parity). The selectability term is defense-in-depth: a runtime-capable harness that is not operator-selectable must not be spawnable here from a config object that skipped the loader's normalisation. The eviction term is what decides whether a harness that runs on the shared runtime may also serve this path. Background handles are the high-churn ones — title generation, suggestions, folders and nav each take their own ephemeral `sessionId`, many per conversation, at a rate the user never controls — so a teardown that does not evict is unbounded growth here. Membership in `ACP_BACKENDS_SESSION_EVICTION` is the claim that the teardown verb Crew sends actually frees one session, and it is earned by measurement. Codex is a member on that basis: its teardown is the standard `session/close` sent as a request, after which the same `sessionId` no longer answers `session/set_config_option` (measured live against codex-acp 1.11.0). `session/cancel` would not qualify: after it the session keeps answering and a further `session/prompt`'s `cachedReadTokens` shows the context resident — `cancel` interrupts a turn, it does not end a session. The delivery is part of the fact: the same `close` sent as a notification is ignored and evicts nothing, which is why the harness declares `notification=False` and why a gated live test re-measures both on every install with the adapter. The background path is not the only reader. `AcpSessionProvider.new_conversation` — the warm-reset primitive the workflow pool reaches for — reads the same set, because the whole reason that path is cheap is that `old.destroy()` reclaims the previous session on the already-running process. On a non-evicting harness that call frees nothing, so each pool reset would leave one more resident session, and here the "person opens chats" bound does not apply: a pooled workflow resets at whatever rate its steps run. Nor can a recycle rule be relied on to rescue it, because a rule that measures a narrower scope than where the sessions live never sees the growth, leaving the age ceiling as the only reaper. So a non-evicting backend is REFUSED there before any session is created, and `WorkerPool.reset` takes its existing hard-reset fallback — slower, and correct for every harness. Refusing before the `session/new` rather than after is the whole point: creating first would leak exactly the session the refusal exists to prevent. - **runtime-capable backend** (`_bg_runtime_backends()`) — each caller (title generation, suggestions, folders, nav) gets its **own** ephemeral `sessionId` multiplexed on a single shared `_bg_runtime` (an `AcpRuntime` spawned under the CONFIGURED backend), created lazily under `_bg_runtime_lock`. `create_session()` runs **outside** the lock so independent callers aren't serialized. The runtime is respawned-and-retried once on `AcpRuntimeDead` (`max_retries=1`, 2 attempts total). - **any other backend** — falls back to a `_ProviderBgSession` over the shared `BACKGROUND_KEY` `_Session`, serialized by its `Semaphore(1)`. This branch serves every selectable backend outside `_bg_runtime_backends()` (today claude, opencode, pi, goose and deepseek). Two conditions displace the cached `_bg_runtime`: a **backend switch** and **staleness** (`AcpRuntime._is_stale()` → `"age"` past 6h, or `"rss"` past 500 MiB across the descendant tree; on macOS each pid of that tree is measured by its `phys_footprint`, falling back to its `ps` RSS, because `ps` RSS omits the compressed and swapped pages an idle grown runtime mostly consists of). The displacement policy has ONE implementation, `_detach_bg_runtime_locked(runtime, cause, *, park_only=False)`: the runtime is killed if idle, and **parked on `_draining_bg_runtimes` if it has live or initializing handles** — parked runtimes never receive a new session (only `_bg_runtime` is offered to callers), their in-flight work finishes untouched (killing mid-turn would abort an in-flight title generation), and `_reap_drained_bg_runtimes_locked()` kills each once its last handle drains. Either way the slot is freed in the same lock hold that spawns the replacement, so the very next background call runs on a fresh process even while the old one is still draining. `cause` is threaded into every log line the displacement emits, because a staleness recycle and a backend flap have different remedies and must not read alike. Two paths reach it. The backend-switch adapter `_displace_bg_runtime_locked(runtime, cached_backend, configured_backend)` is called from `_retire_stale_backend_bg_runtime()` and from the `acp_backend` mismatch check inside `get_bg_session()`'s runtime branch (the mismatch outranks staleness). The staleness probe sits in that same branch and is run on **every** eligible runtime, busy or idle, using the full `_is_stale()` predicate rather than the cheap age-only `_stale_by_age()`: waiting for a zero-session window is not a bound, since a multiplexed runtime under sustained background load never has one, and RSS — not age — was the growth mode observed (multi-GB over ~24h). The cost of probing the busy path too is that `_bg_runtime_lock` is now held across `_is_stale()`'s offloaded RSS read for busy runtimes as well; that is bounded by `_RSS_PROBE_MIN_AGE_SECS` (5 min), below which the probe returns without any executor round-trip, and a runtime that answers "stale" is displaced rather than re-probed. `_draining_bg_runtimes` has **no cap** — a retiree whose handles never drain stays parked and sweep-shielded — so the `%d _bg runtimes are parked draining` warning is the signal that displacement is outpacing the drain. Parked runtimes stay shielded from the orphan-PID sweep (`_companion_runtime_pids`), block the account-identity sweep's completeness (`_retire_kiro_bg_runtime`) while they drain, and are reaped by a periodic watchdog hook (`bg_drain_reap`) as the backstop for an idle gateway where no other trigger runs. That same hook carries the **idle-staleness sweep**, `reap_idle_stale_bg_runtime()`, and it is not housekeeping. Both displacement triggers above live on the REUSE path in `get_bg_session()`, and the shared runtime's pid is sweep-shielded for its whole life, so a runtime that stops being reused is never asked `_is_stale()` again and is reaped by nothing else: it lives until the gateway restarts. Measured on an operator host: 11 agent runtimes holding 6.0 GB, six of them 14.5 h old and five of those over the 500 MiB ceiling, against 4 live slots. The sweep asks the question off the reuse path, under `_bg_runtime_lock`, and retires only a runtime with NO active or initializing session. It PARKS rather than kills, through `_detach_bg_runtime_locked(..., park_only=True)`, and that flag exists for one reason: `get_bg_session()` pins the runtime under the lock, RELEASES the lock, and only then calls `create_session`, which is where `_session_inits_in_flight` rises. A kill inside that window surfaces as `AcpRuntimeDead` on a caller that did nothing wrong; parking cannot, because the drain reaper re-probes on a later tick, by which time the pinned start has either registered (busy — it stays parked) or failed. The slot is re-read after the awaited staleness probe, so a concurrent displacement that replaced it is never acted on with the old reading. Both probes fail toward PRESERVING the runtime: a session or staleness probe that raises retires nothing. `close_all()` detaches both holders atomically under `_bg_runtime_lock` and kills the detached snapshot; its counterpart `_closing` gate in `get_bg_session()` — and in the idle-staleness sweep — refuses to spawn or park once shutdown has started. Note there is currently no dashboard edit surface for `agent.acp_backend`, but a file or CLI edit does not wait for the next gateway start: the config watcher dispatches it as a `_FACTORY_CONFIG_PATHS` change, so `refresh_defaults()` rebuilds the factory and retires stale-backend background runtimes on the write. A future edit surface gets retirement for free by routing through it like the other `agent.*` defaults. The provider-path retirement trigger is live whenever the switch is to or from one of the provider-path backends above. Both paths yield `AcpEvent` through the shared `acp/_dispatch.parse_session_update` parser, so there is no behavioral drift between them. Callers **MUST** call `session.destroy()` in a `finally` block when done. See [acp-client.md](acp-client.md) for `AcpRuntime` / `AcpSessionHandle`. **Cheapest-model bg tasks**: the categorical/classification background tasks (folder-icon `chat_folders.py`, link-summary `chat_nav.py`, session title `chat_title.py`, session-summary `handlers/sessions.py`, STT endpointing `stt_stream.py`, and the lesson-contradiction check `dashboard/handlers/cron.py`, plus tips generation) express a `"auto"` model preference and pass it to a best-effort per-session `set_model`. The wire chokepoint (`AcpSessionHandle.set_model` → `resolve_usable_model`) mirrors the interactive `_wire_model_id`: it sends a served id (a persisted pin carrying a stale `::` qualifier is folded to the advertised spelling via `resolve_pin_spelling`), sends `"auto"` only when the backend advertises it, and for anything else — `"auto"` where a partition doesn't serve it, or an unentitled concrete id — resolves to `""` and **skips the send**, inheriting the session's served backend default. So these tasks never put an unserved model or a literal unavailable `"auto"` on the wire (which would fail with `Invalid model ID`). A reactive retry in `run_bg_oneliner` (retry once with the first advertised model on a mid-prompt rejection) remains a thin backstop for the fail-open case where the advertised set was unknown at send time. ## Account-identity retirement (identity sweep) A kiro-backed child authenticates from the CLI credential store as its process starts and keeps that account for life, so an out-of-band account switch or logout leaves running children answering turns on the previous account. The retirement machinery detects and recycles them, in `session_lifecycle.py` (`retire_kiro_identity_sessions`) driven by the per-turn gate in `chat_runner.py` (`_retire_sessions_on_identity_change`), against baselines owned by `KiroPrerequisiteService`. The gate runs before each dashboard chat turn acquires its session (`_run_chat`) and before each agent cron run does (`_acquire_with_model_fallback` in `slack/gateway.py`, every `agent_sequence` step included). A `cron:` key never claims a warm-pool process (`bypass_stateless`), so the child that can still hold the previous account is the cron's own session: a fire whose sub-agents are still running skips the reset in its `finally`, and the next fire of a `persistent_session` job, or of the same `agent_sequence` step, reuses that live session. A host where only crons run has no dashboard turn to retire it. A cron run is an unattended caller (`unattended=True`). An empty live fingerprint is never reconciled and its sweep spares nothing. After a sign-out that is wanted, but a store the reader cannot identify reads empty on every call, so a cron sweeping on it would retire every idle kiro-backed session on every fire, cancelling idle parents' `spawn_run` children. The cron's own session survives from one fire to the next mostly while its sub-agents are still running, so retiring even that one would usually end them. So an unattended caller sweeps under an empty fingerprint only when `identity_absence_is_definitive` says the read proved nobody is signed in, and only until one sweep under that sign-out completes (`identity_absence_already_swept`; a read that proves anything else starts a new sign-out). After that sweep nothing that held the old account is left. A session started since holds no account or brings its own `KIRO_API_KEY` (a cron job's `env` block), which no sweep spares, so sweeping again would only cancel its sub-agents. Otherwise it touches no session. **Identity fingerprint** (`current_identity_fingerprint`): one string over every credential source a child may have loaded, each kept as its OWN component so a failed read drops a component rather than changing one: the kiro-cli store's hashed stable claims, then `+key:` for Kiro CLI's own `KIRO_API_KEY` (read from the environment, falling back to the data home's `.env`, as the `whoami` probe does), then `+crew:` for the Crew vault. The key and vault components appear only when present, so a host with neither fingerprints exactly as the store alone. An API-key host keeps no identity row in the store, and without the key component it read as signed out on every turn — every dashboard send retired idle sessions and cancelled their `spawn_run` children (#15126). The key joins only a DEFINITIVE store answer (read and audited with every credential row identified, or no store file at all): after an unauditable, unreadable or relocated store read, or one that found a login no stable claim identifies (a social login), only the key component is withheld and the fingerprint is exactly what it was before the key was counted, because a child can authenticate from the store rather than the key, and a key-only baseline would let a store account switch compare equal. With each value the service also records whether that read proved nobody is signed in (`identity_absence_is_definitive`): the value is empty, the store's answer definitive and from the only store the host can have (every anchor the store can live at holds a file or `stat` reports it missing, and at most one holds a file: with both Windows stores present the choice rests on write times that may not be readable), the API key's `.env` absent or cleanly read, and the Crew vault's entry map read with no identity entry, or missing. Every source reports a read it could not complete, or a path it may not look at, as empty too, so an empty value with any source unread proves nothing. A child whose per-session env overlay (`extra_env`, e.g. a cron job's `env` block) names `KIRO_API_KEY` is never spawn-stamped and never spared by the sweep: the fingerprint reads the gateway's credentials, and that child may have authenticated as a different account. A spawn stamp proves a wrong account only through its store or vault component, never its key component: a harness that strips the key (a Crew-owned KAS relay) is unaffected by a key rotation, which the ordinary baseline comparison still reports. **Boot-seeded baseline** (`seed_sessions_baseline`): the running-children baseline is adopted from the store at gateway startup, before anything can spawn a kiro-backed child, so every child postdates the read. This removes the once-per-lifetime unset-baseline sweep, whose completion precondition (nothing busy, nothing mid-start) is routinely unsatisfiable on a live gateway — retired idle sessions are eagerly respawned by dashboard slots, the next sweep reads incomplete, and the baseline never advances, recycling healthy children forever. The seed refuses (keeping the fail-safe sweep) under `assume_ready`, when a baseline is already recorded, when the identity cannot be fingerprinted (an unreadable store with no API key, or any store read that is not definitive), and when the read hangs past a 5s bound. **Interim latch** (`_maybe_latch_interim_identity`): a bare baseline comparison is blind to an A→B→A round trip, so any fresh read (status polls, turn gates, probes) that observes a different account than the baseline arms a sticky flag forcing the next turn gate to sweep even after the store switches back. Observations that could be transient read failures refuse to latch — component LOSS is indistinguishable from a blip, while a component that appears or changes cannot be one — so an unreadable store can never arm it. Each qualifying observation also bumps an observation GENERATION; the gate captures the generation right after its initiating read and hands it back at reconcile (`note_sessions_reconciled(live, observations_before=...)`), which keeps the latch armed when a newer observation exists: that account was observed after the sweep's coverage, and a child spawned under it may be unstamped and otherwise invisible. **Spawn-identity stamps**: every spawn site bracket-reads the store immediately before `start()` and after, and records the account on the provider (or the shared `AcpRuntime` for demuxed sessions) only when both reads agree — a disagreement or failed read refuses the stamp, and either read observing an interim account feeds the latch. Stamps are first-stamp-wins (a warm-pool provider keeps its fill-time record), the stamp read is shielded from orphan cleanup by `_starting_pids`, and every stamp await sits under a teardown guard so cancellation cannot leak a started child. An unstamped child keeps exactly the pre-stamping protections. **Live-account spare** (`spawned_under`, inside the sweep): a session or companion runtime whose spawn stamp EQUALS the live fingerprint — the whole fingerprint, every component — provably authenticated as the live account and is skipped by `retire_kiro_identity_sessions`: not retired, not flagged, and not counted against completeness (the runtime reapers take the fingerprint as `live=` and apply the same test to their post-conditions). For a child that never received `KIRO_API_KEY` (a Crew-owned KAS relay and every foreign backend strip it at spawn; a cli-owned KAS relay is handed it, like kiro-cli) the key component is left out of that comparison, so a key rotation does not retire its idle parent and cancel its running children. Without it the sweep retired every kiro-backed idle session and could complete only when every kiro-backed holder was idle at once, which a busy gateway never is: each turn re-swept, and an idle parent whose `spawn_run` children were still running was retired with them — every subagent on the host died as `AcpProcessDied ... (provider shutdown)` on every turn any chat took (#14605). The spare is the strict converse of the mismatch gate: a mismatch is proven component-wise (a read can only LOSE components), a spare needs exact equality, an unstamped child is never spared, and an empty live fingerprint (unreadable store, and the sign-out path, which sweeps with no fingerprint on purpose) spares nothing. The sweep's release is pinned to the runtime it inspected (`release_subagent_runtime(key, expected=runtime)`): it decides from a snapshot and then waits for the per-parent spawn lock, and a respawn holding that lock installs a live-stamped replacement under the same key before letting go, so a pop by key alone would kill the runtime the spare exists to keep. A release whose pin no longer matches pops nothing, returns `False`, and leaves the replacement to the post-condition. A channel member holds its lease for the channel's life (`lifecycle_lease`), so the sweep always reads it as busy. One stamped with the live account takes the same spare; one on another account is flagged and keeps the sweep incomplete. An incomplete sweep records what it is waiting on (`identity_sweep_waiting_on`: session keys tagged `busy` or `channel member`, or the runtime that stayed up), and the turn gate logs it at WARNING so an operator can find the holdout. **Live-children deferral** (`spare_children`, decided at the async teardown): an idle parent whose stamp does NOT match the live account but which still has a child — live or queued in memory, admitted into the teardown fence since it opened, or a durable-only row in the task store — is not retired on the per-turn sweep. It is deferred the way a busy session is (flagged `retire_on_identity_change`, kept in the map, old-account sid cleared, recorded ` (subagents)` so the sweep reports incomplete). The per-turn gate fires on every chat turn, so without the deferral an idle parent with running children was retired and its children cancelled as `AcpProcessDied ... (provider shutdown)` on every turn (#17360). The deferred parent is refused its next turn by session allocation (`retire_on_identity_change`) and evicted by `_evict_stale_session`, which shuts the parent's provider down WITHOUT `_cancel_parent_children`, so the children survive and their completions reach the successor under the key. The decision is made at the ASYNC teardown, not from a probe taken earlier: under the registry lock a `spare_children` candidate is NOT retired — its teardown fence is opened (the `_teardown_fence_lock` the admission path records a new child into before its store write) and it is left registered, so nothing irreversible happens for it under the lock. Off the loop the sweep asks whether a child still exists (in memory, in the fence, or in the store); a child, or a failed store read, defers it, and the next sweep re-checks. Only a childless parent is retired, under a re-taken lock that leaves a successor registered under the key alone and defers again if a turn took the parent's semaphore during the read. The probe is side-effect-free (not the teardown `snapshot_teardown_children`, which arms the delivery gate, clears follow-ups and cancels follow-up watchers). **Cost of an incomplete deferral.** A deferred parent keeps the sweep incomplete (it is recorded ` (subagents)` and the pending fingerprint is NOT cleared), so the per-turn gate re-runs `retire_kiro_identity_sessions` on the next chat turn from any session — the incomplete sweep is its own trigger — and re-checks that parent. That re-check runs inside the start-permit barrier (`owner._start_sem.drain()`): the barrier makes the registry scan authoritative over cold starts, and the off-loop `_parent_has_sparable_child` read for each deferred candidate is awaited while it is held, so each cold start waits behind that read until the candidates resolve. The deferral is intended to be SHORT-LIVED relative to a `spawn_run` child: it clears the moment the parent is either genuinely childless (committed and retired) or takes a turn itself (which retires it through the `retire_on_identity_change` eviction path). It is NOT bounded by a timeout — a parent with a long-running (hours-long) child stays deferred, and re-checked each turn, for the child's whole life, and the account change is not reconciled for that key until then. This is accepted because the alternative the deferral replaces is worse: without it the parent is retired and its children are cancelled as `provider shutdown` on EVERY turn, not deferred on every turn. The per-check cost is kept small — the in-memory and fence checks are synchronous, the store read runs on the writer thread and only its existence is tested (it lists the parent's pending rows, the same reading the teardown's own cancel uses, and the probe answers on the first row), and a childless or turn-taking parent drops out of the candidate set — but it is a real per-turn cost on a host that holds a long deferral, and the barrier is held across it. A caller that wants the account reconciled sooner retires the parent itself (its next turn, or an explicit close); the sweep does not force the issue while children run. The deferral is SCOPED by the live fingerprint AND by the parent's own spawn stamp. `spare_children` defaults to False, so the sign-out path (`_retire_runtimes_after_sign_out`, which sweeps with no fingerprint) keeps the cancel behaviour — on sign-out the account is gone, so an idle parent and its children MUST be retired, or the children keep running on the signed-out account's in-memory credential. `chat_runner`'s per-turn identity-change sweep passes `spare_children=bool(live) and not service.identity_component_dropped(live)`: an empty live fingerprint is an external `kiro-cli logout` (which never reaches the sign-out handler, so the per-turn sweep is the only path that catches it) or an unreadable store, and `identity_component_dropped` additionally forces the cancel path on a PARTIAL loss — a credential source present in the retire baseline but gone from `live` (a logout that leaves the Crew vault populated, so the combined fingerprint is still non-empty). Inside the sweep a second, per-holder check uses the parent's OWN spawn stamp: a parent whose spawn identity lost a component (`identity_component_dropped(spawn_identity_of(provider), live)`) is retired and its children cancelled, because the baseline advances only on a complete sweep and so lags a source added-then-removed inside an incomplete one, while the spawn stamp is the identity the children actually loaded. So children are cancelled on sign-out, an empty/unreadable fingerprint, a dropped baseline component, OR a dropped spawn-stamp component; only a whole switch between two real accounts defers. **Per-turn stamp gate** (`flag_identity_stamp_mismatches`, before the unchanged early-return in the turn gate): a session whose stamp provably differs from the live account is flagged `retire_on_identity_change` and its resume sid cleared (mirroring the sweep — the flag makes `close_all` skip the pointer re-map, so an uncleared sid would survive restart and hand the replacement child the flagged account's conversation). A proven-mismatch companion runtime — busy or idle — is displaced out of its claimable slot (`_subagent_runtimes` / the `_bg` slot) and parked: `_draining_subagent_runtimes` for companions, the existing `_draining_bg_runtimes` for the background runtime. Kills happen only on a LATER drain pass, once idle and past a park grace (`identity_park_grace_remaining`) that outlasts the sub-second window where a just-claimed runtime still reads idle. The companion drain reap removes only the entries it actually killed — the kill awaits, and a rebuild from a pre-await snapshot would drop a concurrently parked runtime from the only list that still references it. Parked runtimes stay PID-shielded, count against sweep completeness, and are torn down at `close_all`. ## Key Behaviors - **Empty-response recovery ladder** (dashboard chat runner, depth-0 turns only): a completed turn with no visible output, no refusal reasons, and no cancellation is treated as a transient provider failure and recovered through a bounded three-rung ladder driven by `slot._empty_response_retries`, with `slot._empty_episode_productive` carrying one episode-scoped fact the counter cannot (rung 3 below): 1. **first empty** → the ORIGINAL message is silently re-queued at the front of the slot queue (no visible card). Reached ONLY by a turn with no activity — see the productive-turn exclusion below; 2. **later empties** (the same-message retry also produced nothing) → a synthetic continue nudge (`_EMPTY_AUTO_CONTINUE_MSG` — a DIFFERENT message, since re-sending the identical prompt tends to reproduce the identical empty generation) is queued on the SAME live session, with a transcript-visible notice card. The budget is `session.empty_response_max_continues` (default 1 — one nudge, notice "auto-continuing once"; above 1 consecutive failures keep continuing and the notice shows "recovery N of M"). Gated by `session.empty_response_auto_continue` (default ON; the gate fails open), and suppressed while a Stop is active; 3. **budget exhausted** (the nudges also produced nothing) → terminal notice card asking the user to send a message; the counter resets so the next genuine user turn gets a fresh budget. The card's sentences are the constants in `messaging/empty_turn_copy.py`, shared with the channel driver's empty-turn verdict so a channel thread mirrored into the dashboard reads one story. The card's wording is cause-aware, mirroring rung 2's split, and the split is decided per EPISODE, not per turn: the card says the turn ended without a closing reply and that completed steps will not re-run whenever THIS turn was productive **or** any earlier turn of the same episode was (never "returned nothing", which is false for such an episode and — read back by the model via the transcript — invites a redo of landed side effects). The episode half is what `slot._empty_episode_productive` carries: set at rung 2's productive branch, and cleared BOTH beside the counter on a landed turn AND at the start of any dispatch the drain did not re-queue as recovery. `_synthetic_payload` is true for a CONTINUATION-tagged entry and for an untagged one, which falls through to the entry's kind — so the auth-required and poisoned-conversation requeues count even though they carry the user's own text. It is false for an ORIGINAL-tagged verbatim replay, which only rung 1 queues, and rung 1 requires the counter below 1 while the flag is only set with it at 2 or more, so the flag is already clear on that path. The second clear is what makes the flag independent of the several controls that discard a queued continuation without landing a turn (the hard-kill Stop's queue clear, a rewind commit's rebuild). None of those resets the recovery counter, so a spent counter can still route a LATER request into rung 2, and that request's own turns must not inherit this episode's evidence. The counter's own staleness across those controls is pre-existing and not redefined here. The episode is deliberately NOT keyed on the two continuation bodies. The runner queues other recoveries of its own and can put one AHEAD of this ladder's — a Stop hook's `decision: block` prepends at index 0 in the same teardown that queued the ladder's continuation there — and those turns are the SAME episode, whose productive first turn has already run its tools, so they keep the no-rerun wording instead of inviting a resend. A body test also could not be identity on its own, since the bodies are fixed runner-authored strings a user can paste. The rest of the split is needed because the counter cannot separate the two paths that both arrive at 2 (a productive turn's `= 2` jump and the plain ladder's two `+= 1` steps), and the per-turn `EmptyTurnActivity` is rebuilt every turn, so a productive turn whose continuation returns nothing would otherwise be described by its empty continuation alone. The predicate is monotone: it can only move a card from the counter wording to the productive wording, never back, so no already-correct card changes. For an episode that was NEVER productive the recovery clause appears only when the counter shows budget was spent, and claims only that automatic recovery was attempted — the counter counts budget, not which rungs ran (with the auto-continue gate off, give-up arrives at one with no auto-continue). Give-up with the counter at zero is reachable non-productive only on nested depth>0 turns, where the card reports only the empty turn (the gate-off zero-counter path is productive by construction and takes the productive wording). **A PRODUCTIVE turn never reaches rung 1.** "Empty" at this branch means only that the FINAL assistant segment is empty, which is not the same as "the turn did nothing": `assistant_text` is reset at every tool boundary, so a turn that streamed an answer and then called a tool arrives here with its answer already flushed, persisted and on screen, and a tool-only turn arrives here having run real side effects. Rung 1 re-queues the user's own message, so for either shape it re-executes completed tool calls (a second `send_message`, a second write, a second PR) and re-derives an answer the user has already read — observed in the field as two consecutive billed `end_turn` turns, each with a preamble and successful tool calls, both classified empty and the first verbatim-replayed. `chat_utils.EmptyTurnActivity.productive` is the guard: a flushed visible segment, a dispatched tool call, or thinking. A productive turn skips to rung 2, which carries `_ACTIVITY_NO_REPLY_CONTINUE_MSG` instead — the same `EMPTY_RESPONSE_RECOVERY_PREFIX` marker (so no new recovery card or locale pair is needed) with a body that does NOT claim the turn produced nothing, because that body is read by the model and would invite it to redo work whose side effects already landed. Its notice card differs for the same reason. The ladder bound is unchanged: a productive turn spends the same budget, it simply never spends it on a replay. `_produced_visible_output` deliberately does NOT cover this case — its narrow meaning (only the mid-turn resets that are not tool boundaries: steer cut, compaction, clear, agent switch) is load-bearing for the promise-only guard. **A terminal directive is not an empty turn.** One directive is a turn's intended last act (`session_directive_apply.TERMINAL_DIRECTIVES`): a `nothing_to_do` quiet end, the sanctioned way for a turn that ran tools and found nothing the user needs to read (a patrol cycle with no change, a cron wake with nothing to report) to end without a reply. The runner reads the applier's structured `DirectiveOutcome.ends_turn` — set only where the effect landed, never derived from the outcome prose — into `_terminal_directive_applied`, and the empty-response branch is skipped for such a turn: no notice card, no synthetic continuation, no recovery budget spent. The tool card's applied outcome (`QUIET_END_OUTCOME_PREFIX` plus the model's optional `note`) is the low-key, inspectable record that the turn ended on purpose; nothing is rendered as an assistant bubble. A refused directive leaves the flag unset so the model falls back to a plain-text reply. The turn-end contract the base prompt states — a closing text, or `nothing_to_do`, never a bare stop after an ordinary tool — is enforced by exactly this asymmetry: the bare stop keeps the ladder. Model activity AFTER the terminal directive (a text chunk, another tool call) is a contract violation the runner counts and reports once at turn end as a privacy-safe WARNING (`Turn-end contract violation`, counts and the directive kind only); it does not fail the turn and does not re-arm recovery, because re-arming would turn the quiet end the model asked for into a notice card. **Who may end quietly.** The applier's one gate is who opened the turn: a monitor wake (`_directive_self_wake`) or a headless producer (cron, crew, app, task runner — `_directive_user_origin` False) may; a turn a person opened (`_directive_user_origin` True, or any channel turn) is REFUSED (`QUIET_END_REFUSED_USER_TURN`), so the ladder, Resume and the channel notice run as before and a model that dodged a question fails VISIBLY, not silently. A quiet end is also a FINISHED turn for the interrupted-turn scan: its tail (`[nudge, tool…]`) is shape-identical to a gateway that died mid-turn, so `state.is_turn_interrupted` and its mirror `selectTurnInterrupted` treat the applied directive's tool row (`is_quiet_end_row` / `isQuietEndRow`) as a terminator like the Stop card — otherwise every quiet patrol cycle would offer Resume and flag the session as interrupted. Two structured facts, both required: the row's persisted TRUSTED identity (`meta.tool_name` + `meta.mcp_server`, never its title), and `meta.ends_turn = True`, which the runner stamps only on the rows of a directive whose `DirectiveOutcome.ends_turn` was True (persisted, and patched live over `chat_message_update`) — a refused call carries the identity and no flag. And only when it is the turn's LAST tool row: a tool row later than it means the model kept working past the directive, and a gateway that died in that work must not hide behind it. `test_nothing_to_do_directive.py` pins the two constants against the selectors mirror. The Crewmate chat draws nothing for it: tool rows are machinery there (`crewmateBubbles.MACHINERY_ROLES`), so the quiet turn leaves that surface exactly as it was. The ladder's own cards — the continue and give-up rungs and the post-compaction resume — carry `meta.kind = "empty_turn"` (`chat_utils.EMPTY_TURN_NOTICE_KIND`), and the Crewmate chat drops a `notice` row by that tag (`crewmateBubbles.isCrewmateChatRow`), never by its words: a person reading a crewmate has nothing to do with the runner's recovery, while an untagged notice (an automation arm refusal) still draws because it names something they may have to act on. The Sessions page keeps drawing them. `ask_question` needs no exemption here: the tool BLOCKS and returns the user's answers as its own result, so the turn that asked continues and ends with ordinary visible output rather than parking on an unanswered card. **Turn-end diagnostics.** The branch emits ONE privacy-safe WARNING per empty verdict, after the rung is chosen, naming a closed `cause` and `rung` plus booleans: `provider_empty`, `tool_only`, `thinking_only`, `visible_partial`, `no_terminal_event`, `synthetic_completion` or `other` (`chat_utils.classify_empty_turn`, ranked most-specific first), and `replay` / `continue` / `give_up`. `EmptyTurnActivity` carries whether a terminal `EVENT_COMPLETE` arrived, whether the provider SYNTHESIZED it, the terminal stop reason normalised onto a closed set (`chat_utils.normalize_stop_reason` — an omitted reason answers `absent`, which is a distinct observation from a clean `end_turn` and must not be laundered into one, and an unrecognised backend string answers `other` rather than being echoed), whether text streamed, whether a visible segment was flushed at a tool boundary, whether tools ran, whether thinking ran, and whether the provider reported ANY billing dimension. Every field is a bool or a closed constant by contract: no prompts, responses, thinking, tool arguments or results, paths, identities, token counts or costs. The predecessor logged only `Empty model response (attempt N)`, which could not separate a provider that generated nothing from a turn whose answer a tool boundary flushed away from a turn no terminal event ever closed — three faults with three different owners, and one field incident hit all three in three consecutive attempts. Recovery rungs 1–2 skip persistence/consolidation/success-recording (the empty turn is never saved) and preserve all other retry budgets. Synthetic recovery messages (`_SYNTHETIC_RECOVERY_MSGS`: the post-transient CONTINUE instruction and the empty-response nudge) are excluded from the genuine-new-turn allowance reset, so a recovery turn can never refresh its own budget; on the queue-drain path they classify as **recovery** STRUCTURALLY — ``queue_insert`` tags the entry ``kind="synthetic_recovery"`` and every queue consumer (merge predicate, sub-agent hold, drain-role assignment, reset-notice consumption) dispatches on that metadata, never on content equality, so classification survives queue transformations and a user pasting the recovery text verbatim still classifies as plain user speech. The transcript append uses the `inject` role (never `user`, so an internal orchestration instruction is never persisted as user-authored history or mirrored to linked channels), draining one does not cancel a pending synthesis, and the tag is merge-breaking so a nudge is never folded into a `[N queued messages merged]` user turn. At `_prompt_depth > 0` the ladder is disabled entirely (terminal notice on the first empty) to prevent nested-turn re-queue loops. - **Leaked tool-call notice** (dashboard chat runner, depth-0 turns only, issue #6112): a turn that ends normally with an invoke block emitted as TEXT and zero tool calls executed — the model wrote its invocation into the prose channel instead of dispatching it (observed with deferred MCP tools whose schema is not yet bound, and with large nested arguments) — surfaces a visible notice card and is marked un-landed (no success recording, no budget reset, no consolidation), so an unattended monitor/autonudge cycle that leaked never lands silently. Detection is machine-shaped (`chat_utils.has_leaked_tool_call`): an unquoted invoke open tag plus a parameter or close tag, with fenced code blocks and inline code spans stripped first so a pasted transcript or explained example never matches; the gate (`should_notice_leaked_tool_call`) is claimed ahead of the promise-only guard. Deliberately **notice-only** — no continuation is queued, because an injected "re-issue that call" would carry runtime authority into sessions where the call auto-approves (slot trust, global yolo, or a static agent tool allowlist, the last invisible at the runner layer, so no fail-closed downgrade condition exists) and the leaked block may be untrusted external content the model merely reproduced. A loop loses one cycle, visibly, and retries on its own schedule. Scope limit: the notice/un-landing applies only to ZERO-tool-call turns — a mixed turn that executed tools and then leaked its final dispatch as text lands normally (un-landing a turn whose earlier calls had real side effects would misdescribe it) and gets its own notice card, worded for that shape: the earlier calls were ATTEMPTED and may already have taken effect, so the reader is told to check what landed rather than that nothing ran (`should_notice_mixed_turn_leak`). - **Leak dropped at a compaction boundary** (issue #11995): a REAL (non-synthesized) mid-turn compaction terminal is a segment boundary, so the runner clears `assistant_text` there — text streamed before the summarization belongs to the window that was just summarized and must not carry into the segment flushed afterwards. Both gates above read that accumulator AT TURN END, so a leak that streamed BEFORE the boundary was invisible to them and both declined on an empty segment, while the raw invoke block had already reached the user (chunks stream to the wire as they arrive) and the boundary flushes nothing. The turn then took the post-compaction continuation arm precisely BECAUSE the segment was blank, so the session showed raw machine syntax followed by "continuing automatically", ran no tool, and accounted for neither. The scan therefore runs AT the boundary, before the clear, and only the resulting boolean travels to turn end (`should_notice_compaction_dropped_leak`); a scan placed after the clear reads an empty string and silently restores the defect, which is why the ordering carries a source-level ratchet, and the recorded fact ACCUMULATES (`or`) so a turn crossing two boundaries does not lose the first one's leak to a later clean segment. Strictly notice-only and weaker than both siblings: it un-lands nothing and sets no flag a recovery arm reads. Because it owns no turn outcome it is evaluated OUTSIDE the `if`/`elif` chain its siblings sit in — every arm of that chain owns the outcome and two are recoveries (the L1 infrastructure retry, the promise-only guard), and since these gates exclude neither shape, an exclusive slot would starve a turn that both dropped a leak and needed a recovery. One turn still gets one leak card, enforced by a flag the siblings set (`leak_already_noticed`) rather than by position. It needs no tool-count gate (that gate protects un-landing, and there is nothing here to un-land). - **False current-tool-blocker recovery** (dashboard chat runner, depth-0 turns only): a normal turn that successfully completed only host-owned read/search/fetch preparation tools and then claims that *this turn* exhausted its tool budget or can no longer execute tools is not allowed to land as completed work. Kiro Crew receives real tool failures through refusal/error/result frames; a call lacking a terminal `status=completed` result has no final completion proof and remains ineligible. With no such failure and exact completion evidence, that self-referential blocker is model-authored rather than a runtime verdict. The turn uses the existing promise-only one-shot continuation and non-landing accounting, so the next turn tries the pending action instead of asking the user to type "continue." One provider-canonical identity and call id are retained per dispatch; the id set must exactly equal the final `status=completed` result set. Only the fixed non-MCP builtin allow-list is eligible. Its five shared read/search/fetch identities come from the host approval gate's proven read-only registry; `introspect` and `tool_search` are explicit replay-only discovery extensions and do not widen that auto-approval registry. This replay gate deliberately does not inherit the separate approval path's `tool_kind` classifier: replay can repeat an already completed turn, so it requires a positive Kiro-backend capability (declared fail-closed on the provider ABC), `tool_identity_trusted=true` minted from an adapter-authored canonical tool-name marker (`_meta.kiro.toolName` on the eligible Kiro backend), stable canonical identity, host ownership, and final-result proof. A familiar non-empty `tool_name` is not provenance. The allow-list is specific to Kiro's canonical builtin identities; KAS and other selectable backends remain manual until they expose equivalent identities and round-trip tests. Agent-influenced ACP `tool_kind`, MCP tools, unknown identity, duplicate/missing ids, and every count/status mismatch fail closed. The blocker must be a full match of one of the two captured incident statements; other wording, appended instructions, or announced actions is ineligible, including prose copied from a fetched document. Blocker-adjacent wording that misses the exact grammar remains ineligible but emits a text-free WARNING after otherwise proven read-only preparation, making recurrence drift observable without granting replay authority. Recovery is limited to authenticated-human turns and replays that exact original user message, captured at runner entry before cancelled-turn, subagent-failure, or silent app-context enrichment. Its provenance is preserved; model-authored blocker, action text, and injected context never become the next prompt or mirrored user speech. This remains safe with static `allowedTools`, MCP `autoApprove`, and hook grants: those mechanisms may skip a tool approval, but the retried authority is still the user's own request. The queued replay preserves the triggering row's validated attachment lists so `[attached_file N]` and `[attached_dir N]` markers still resolve losslessly, including paths containing whitespace. It carries a distinct structural kind so a Stop, follow-up, or steer arriving after enqueue purges it before dispatch and resets the one-shot budget. Rebinding the slot to another session while the replay waits also purges it and resets that budget. If the single replay ends in the same blocker, the spent-budget arm emits a give-up notice and never queues a second replay. No host-authored fresh, resumed, or compacted-session context tells the model that tools became unavailable or assigns a per-turn tool budget; provider failures continue to use their control frames. This remains an interim mitigation for an unsupported model self-report, not a diagnosis of a resume-path provider failure. Phrasings beyond the two captured full statements deliberately remain manual; a new recurrence must be diagnosed before the grammar expands. Any identity or completion shape outside the proven allow-list falls through to the landed-turn behavior, preventing a completed mutation from being replayed. The normal Stop, user-follow-up, pending-steer, approval/refusal, and one-shot gates remain unchanged; trusted or global auto-approve sessions retain the existing notice-only downgrade. - **Compaction threshold precedence** (`SessionManager.effective_autocompact_pct`): the session's own override (the context popover slider, `set_autocompact_pct`), else the default the session's crew member declares (`agents..autocompact_pct`; 0 or a negative value inherits, a positive value is held to the global's 5–90 range at load), else the global `session.autocompact_pct`. The crew is the identity the allocation resolved (`_Session.capability_member`), read against the manager's live `_cfg`, so a config edit reaches running sessions on their next reading and a session with no crew follows the global. The value cannot live in the kiro agent JSON, which kiro-cli rejects on any unknown field. `GET/POST /api/chat/slots/{slot}/autocompact` report it as `agent_pct` with the crew's redacted name as `agent` (both null when none is declared; a slot with no live session answers for the crew its next session starts under) so the popover shows which default a cleared slider returns to. The `kirocrew chat` REPL compacts at the same number for the crew its run resolves to. - **Context compaction**: at ≥ configured threshold (`session.autocompact_pct`, default 70%, valid 5–90), compacts **in place** on a member of `ACP_BACKENDS_COMPACT` — kiro-cli, claude, codex, opencode and goose. They divide by WHERE the done signal lands, which is `ACP_BACKENDS_INLINE_COMPACTION`: kiro-cli sends a `/compact` **prompt** (`session/prompt` + `_kiro.dev/compaction/status` watch — never the string form of `_kiro.dev/commands/execute`, which kiro-cli 2.14.0 exits rc=0 on) and its result arrives afterwards, while claude, codex, opencode and goose finish the whole compaction inside the prompt turn. claude and opencode emit no status at all, and goose emits only a `Compaction complete` chunk; codex reports the compaction as a `tool_call` pair marked `_meta.contextCompaction` (translated by `_dispatch.parse_codex_compaction_update`) that arrives before the prompt response, so its terminal is read from the stream like kiro-cli's. For the two that emit nothing `wait_for_compaction()` answers `completed` from the capability rather than from the queue — but only when the turn reached its own end boundary uncancelled, since a cancelled turn compacted nothing and a false `completed` resets the meter AND arms the cooldown. Awaiting the queue for an inline member instead spends the full compaction wait budget (`COMPACT_WAIT_TIMEOUT_SECS`, raised per host by `session.compact_wait_secs`) and then recycles a session that had just compacted correctly. That answer lives on the WAIT, because every route to a compaction reaches it — the channel entry points and the task runner's context-overflow compaction through `provider.compact()`, the dashboard `/compact` as a chat turn, the autocompact through `stream_command("/compact")`. Every route passes the same budget, resolved in one place: each caller holds the session manager and reads `SessionManager.compact_wait_budget_secs()`, which resolves `session.compact_wait_secs` from the manager's config (re-adopted on every live change, so a standalone `kirocrew run`, which arms no live-config watcher, still honours it). A backend outside that set takes one of three arms, each a positive membership so that an unclassified harness cannot fall into a claim by accident: - `ACP_BACKENDS_HARNESS_MANAGED_COMPACTION` (KAS) is **declined** at the threshold (`"compact_unsupported"`, see the gate ladder below), read through `harness_managed_compaction_backend`. KAS is also in `ACP_BACKENDS_COMPACT`, so a user's manual `/compact` is served (the session handle sends KAS's `_kiro/session/compact` verb), but Crew's own threshold leaves it alone: it summarizes on its own initiative and its `summarization_completed` frame resets the meter, so declining leaves nothing unmanaged. - `ACP_BACKENDS_CONTEXT_RECYCLE` (deepseek) is **recycled**. Its ACP surface carries no compaction of any kind, so declining bounded nothing and the context grew toward the harness's own window. `_recycle_unmanaged` reaches the same destination a failed compaction already reaches, on the reason rather than after spending the timeout that proves it. The arm falls THROUGH the gate rung rather than returning from it, so `unconfirmed`, `in_progress` and `cooldown` still run first — an ambiguous reading must not spend a recycle. - A backend in none of the three is **declined and logged at WARNING**, naming the missing membership. Declining is the safe action, since the alternative ends a conversation and no harness earns that by never having been classified, but it is not a settled condition and the log says so. pi is that case today: it looks inline in SOURCE but has no driven capture, which is the bar `ACP_BACKENDS_COMPACT` holds its members to. Every surface that refuses a manual `/compact` picks among three sentences via `messaging.commands.compact_refusal_arm`, so a surface supplies wording only and cannot disagree with the others about which case a backend is in. The process and session ID survive, so queued/agentic work continues automatically. kiro-cli only: if the in-place compact fails, times out, or the provider lacks native support, falls back to the legacy **recycle** (kill session; context re-injected via `build_session_context()` on next message). A recycle is never forced through a live turn — if the turn semaphore cannot be acquired within the budget, the attempt is deferred to the next turn-end check. A compaction whose IMMEDIATELY-MEASURED effect verdict (a confirmed reading taken right after the attempt, showing a real but < 5-point drop) is still ≥ `_POST_COMPACT_RESET_PCT` (95%) escalates to a reset with the native resume sid cleared in the same tick as the pop (`reset(clear_conversation=True)` via `_reset_still_critical` — promoted from the task runner's post-check, #4686). Deferred (next-reading) verdict settles are deliberately damping-only: that reading includes the following turn's own growth, so it cannot distinguish a failed compaction from a successful one regrown by a large turn — it arms the cooldown but never resets. The escalation is AWAITED (the `compact_if_needed` seam returns `"reset"`), performed BEFORE the compaction callback fires (the callback awaits arbitrary surface I/O, and a turn completing inside that window must not be erased by a verdict measured before it ran), pins the measured session's identity (`expect_session`) so a stale escalation never destroys a replacement registered under the same key, and honors `skip_if_busy` (a declined reset maps back to `"ok"`; the still-critical session re-attempts the whole compact-and-escalate cycle at its next threshold crossing after the cooldown, with the mid-stream overflow guard covering the interim). There is no prompt-count fallback on this path — the 40-prompt blind backstop belongs to `recycle_background()` alone (see "Context Overflow Protection"). - **Circuit breaker**: force-resets session after 5 consecutive failures. - **Dead provider detection**: `get_or_create()` checks `provider.is_alive()` on the fast path. If the backing process died (crash, SIGKILL, orphan cleanup), the stale session entry is removed and a fresh cold-start occurs with `is_new=True` — ensuring full context re-injection. Without this, the context builder would see `is_new=False` and skip episodic memory, leaving the new ACP process with zero history. - **Per-session semaphore**: serializes concurrent messages on the same thread key. `get_or_create()` acquires; caller must `release()` when done. This includes named workflow steps: retaining conversation state requires `release(cleanup=False)`, not retaining the semaphore between calls. - **Post-semaphore revalidation** (`_reacquire_and_validate`): the per-session semaphore may be held for a full turn, so it is ALWAYS acquired with the global `self._lock` RELEASED (pinning the lock across that wait would freeze session creation for every key and reintroduce a lock-ordering deadlock). Because a session can be recycled/removed or its backing process can die while a caller waits on the semaphore, every reuse path re-checks identity + liveness AFTER acquiring it, through the single shared helper `_reacquire_and_validate(key, sess, reservation=...)`. Its contract: it returns `True` with the semaphore **still held** (caller MUST `release`), or `False` having **already released** it (session went stale — caller evicts via `_evict_stale_session` and cold-starts), or raises `SessionEndingError` having **already released** it when the key's ending fence is up or the caller's reservation was invalidated by one (see the per-key ending fence below; `get_or_create` turns that into a wait for the lift and a fresh allocation, `open_task_session` surfaces it). Cancellation while parked on `self._lock` after the acquire releases the semaphore before propagating, so the key never stays permanently locked. Liveness uses `_provider_effectively_alive` (a dead Claude-Code `per_session` process counts as alive — it reconnects lazily on the next `stream()`). Consolidating this acquire→relock→revalidate dance in ONE place is deliberate: a divergent copy is exactly how the stale-provider bug class gets reintroduced. ALL three multiplexing reuse paths route through it — the `get_or_create` fast path, its won-by-another-coroutine race path, and `open_task_session` (both its fast path AND its lost-race branch, where a task step that loses the registration race would otherwise wait a turn on the winner's semaphore and be multiplexed onto a recycled/dead runtime). A stale winner triggers a bounded cold-start retry (`_WON_RACE_MAX_RETRIES`). `open_task_session` dispatches on the same membership rule `get_bg_session` uses (`_bg_backend_supports_runtime`): a backend outside `ACP_BACKENDS_ACP_RUNTIME` has no shared runtime to open a per-step session on, so it gets a dedicated per-session provider through `get_or_create`. The only bare `semaphore.acquire()` sites are: the helper itself; a brand-new session the caller just created and registered (no recycle window); and `try_acquire` (a non-blocking, no-`await`-suspension atomic take used by out-of-band `/compact`, which returns `False` on contention rather than waiting, so it has no stale-while-waiting window). - **Agent-model resolution cache** (`_resolve_agent_model`, class-level `_agent_model_cache`): the per-agent model pin resolved from agent JSON is cached but invalidated on BOTH the agents-dir mtime changing (a new agent JSON appearing bumps the dir mtime) AND a TTL (`_AGENT_MODEL_CACHE_TTL`, for in-place edits that leave the dir mtime unchanged). Without invalidation an early `"auto"` miss (agent JSON not yet present) would be pinned forever, so a later create/edit of the agent config would never be observed. The scan reads each spec through `agent_discovery._read_agent_spec`, the hardened reader that module documents as the one reader for both agent scopes: `~/.kiro/agents` is user-writable and shared with kiro-cli, so the read is size-capped and refuses a link resolving onto a sensitive target rather than resolving a model out of whatever the link names. A refused spec is skipped like a malformed one, so the resolution falls through to `"auto"` exactly as an absent spec does. - **Idle cleanup**: expires sessions after `session.timeout_secs` (default 60min) of idleness, measured on `session.last_used`, which is refreshed on every acquire and on a successful `release(key)`. Never expires the persistent keys (`_bg`, `_hb`). Dashboard per-tab sessions (`dashboard:{slot_key}`) idle-expire like any other session. A key listed in `session.idle_exempt_keys` never expires on the idle clock, for a chat that automation wakes hours apart and that would otherwise pay a cold start on every wake. `SessionCleanup._idle_exempt_keys()` reads the list at every sweep, so a config write applies at the next one. It exempts the idle axis only: the owner-gone axis below still reaps a listed session whose owning slot has closed, and the RSS recycle never consults the list. A listed key that no slot ever claimed, such as a cron job that runs without a tab, is reaped on neither axis, even after its job is deleted. The RSS recycle, when enabled, or a restart still ends it. So does the identity sweep (`retire_kiro_identity_sessions`, which reads no list). Before each dashboard chat turn and each agent cron run (`_retire_sessions_on_identity_change` in `chat_runner.py`) it retires a kiro-backed one once the live Kiro account is no longer the one it was spawned under, so an account change made outside the dashboard reaches such a session at the next dashboard chat turn or agent cron run. An agent cron run is the exception when the live fingerprint is empty without proving a sign-out (a social login the reader cannot identify, an unreadable or relocated store): it touches no session, so such a change reaches a listed session at the next dashboard chat turn. A Kiro sign-out from the dashboard runs it at once with no live account, which retires every idle kiro-backed session, whatever account each signed in with; a busy one is flagged and retired later (see "Account-identity retirement"). A session is also expired on a second, clock-independent axis: its owning dashboard slot is gone. `SessionCleanup._owner_is_gone()` answers that, and it asks a deliberately different question of two populations. A `dashboard:`-prefixed key is slot-owned by construction, so absence from `CleanupState.active_dashboard_slots` settles it; that set is published by `chat_utils._sync_dashboard_slots`, which sends the *effective* key of every open slot. A key of any other shape, such as a channel-born slot's channel key or a linked slot's `linked_session_key` (`taskrunner:{id}:chat:{tok}`, `cron:{job}`), is slot-owned only if a published live set once carried it. That is recorded in `CleanupState.slot_owned_keys` at publish time and pruned to the keys that still have a live session OR a slot open right now, so the record is bounded by two finite sets rather than by uptime. That prune runs at BOTH ends, each sweep and each publish, because the sweep is not guaranteed to run at all: `session.timeout_secs=0` leaves `idle_sweep_enabled` false, and the sweep is where the other prune lives, so a record bounded only there would grow with every key ever published and never shrink. The two use one expression, so they are idempotent rather than two policies. The second half of that bound is load-bearing: a slot publishes its linked key as soon as it opens, which can precede the first turn that creates the session, and a prune against the session map alone would forget the record while the slot is still open. A key kept only because it is in the live set cannot be reaped while it stays there, since `_owner_is_gone` requires absence from that same set. A key that `messaging.link.is_channel_session_key` recognises is never recorded at all: a dashboard slot opened on a channel conversation publishes the CONVERSATION's own key (`slack:{ts}` and its siblings), which the channel owns, so dismissing that viewer proves nothing about whether the thread is finished and reaping it would tear a live conversation's runtime down mid-thread. The sweep already skips `channel:`-prefixed keys for the same reason; the other channel namespaces do not carry that prefix, so the exclusion is spelled at the record as well. The record describes ONE incarnation, so on the TERMINATED-OWNER path it is released just before that session's `reset`, not left for the next sweep's prune: the prune runs during the scan, ahead of every reset, so a record left behind outlives its session and a later fire arriving under the same key would inherit a claim it never made and be reaped though it never had a tab. An IDLE reap deliberately keeps the record: there the slot is typically still open, which is why the owner-gone test said no, and the record describes the SLOT's claim, which outlives any one session under it. The release sits before the `reset` rather than after it succeeds, so a publish landing inside `reset`'s await re-adds the key, which is the right answer when a slot reopens for it. A `reset` that DECLINES because the session turned busy restores the record itself, because the release described a session that is still registered: without that restore the key would leave this axis entirely while its tab stays closed, since it is absent from the live set, carries no `dashboard:` prefix and nothing else re-adds it, and would fall back to the idle clock. That restore is also conditioned on the terminated-owner path, so a declined idle reset cannot CREATE a claim for a session no slot ever made one for. The record is what makes the axis safe: without it, absence from the live set is equally true of a `cron:` fire, a `taskrunner:{id}:task{n}` step or a `hook:` session that is running right now and never had a tab, so reaping on absence alone would end live work instead of finished work. One further guard applies to this axis only, the live-set re-assert below; the sub-agent probe is asked on BOTH axes, and the call carries a third question the RSS recycle shares. The sweep consults the same `CleanupDeps.has_attached_subagents` probe the RSS recycle uses, fail-closed, because with session sharing on a parent's children run on its runtime after its own turn ends and the busy semaphore cannot see them. The idle clock reaches that probe as well as the orphan test does: a long sub-agent run is exactly what lets a parent's `last_used` go stale, and an idle expiry that skipped the probe would fire `on_session_expire` and reset the runtime the children are still working on. That same wrapper answers a second question FIRST, and synchronously: whether a completion injection is in flight for the key (`CleanupDeps.has_pending_injection`, installed by the gateway over its `_cron_injecting` counter). The gateway raises that counter before awaiting the injected turn's store read, so until `get_or_create` runs the session holds no permit, the delivering sub-agent is already `done` and so absent from the running set, and a closed tab leaves no in-flight delivery either: every other signal reads "finished" while a turn is already committed to that session, and the reset discards the runtime that turn is about to write into. The counter is the only witness, which is why the gateway's own three reset sites consult it and so does the sweep, the fourth resetter. It is read inside the SHARED fail-closed wrapper, so the RSS recycle honours it as well, and an unreadable counter keeps the session like any other unanswerable probe. The sweep asks it in its own right, BEFORE the axis split, so both reap branches honour it: the axis that elected a session says nothing about whether a turn is committed to it, and the clock alone can elect a never-tabbed `cron:{job}` parent whose `last_used` went stale during the very sub-agent run whose completion injection is in flight. Every branch that resets suspends on the sub-agent probe -- the sweep on both its axes and the RSS recycle -- so each reads the counter ONCE MORE after that probe, because an injection starting inside its await is invisible to any earlier read. On the RSS recycle that re-read is the last statement before `reset`. In the sweep it is the first of four post-await re-judges, all synchronous and all asked about the entry the scan carried out: the counter; then the incarnation, on both axes (the key must still hold the session the sweep judged, because `on_session_expire` consolidates the transcript ahead of `reset` and a `reset` that declines on the mismatch afterwards does not undo a consolidation already run over a newcomer's transcript -- and on the orphan axis a departed incarnation loses its old slot-claim record only while the key is still absent from the current live set; a reopened slot republishes a fresh claim for its replacement, which the stale verdict must preserve); then the semaphore (a turn that took it during the await is exactly as live as one the scan skipped); then, on the idle axis only, the clock (a turn that began AND finished inside the await released the semaphore again but bumped `last_used` on its way in, so the session is not idle now -- the orphan axis ignores the clock and re-asserts against the live set instead, below). Nothing between the probe's return and `reset` suspends. Neither read is the atomic one, though: `reset` itself suspends on the registry lock before it validates anything, so an injection beginning while that lock is contended is invisible to every read a caller took first. Both resetters therefore pass `reset(skip_if_injecting=True)`, which asks the counter UNDER that lock beside the identity and semaphore re-validations, so the answer is atomic with the pop. The sweep's own reads stay as a cheap early-out that names its own reason in the log. The option is opt-in, so a user-initiated reset still wins over an injection, and it fails closed locally: an unreadable counter declines that one reset rather than raising through a sweep with other candidates to visit. The guard DEFERS the reap rather than exempting the key: once the counter returns to zero the next sweep expires it, so the runtime this axis exists to release is not held for good by a window that has closed. Every verdict this sweep reaches is about ONE incarnation, so on BOTH axes the reset is pinned to it: the scan carries the session object out with its key and passes it as `reset(expect_session=...)`, which revalidates identity under the registry lock and declines on a mismatch. The probe suspends on either axis, and the orphan axis adds the scan's lock release ahead of it, so the key can change hands before the act -- a cron job firing again, a tab reopened and a turn taken -- and a key-only reset would hand that newcomer a verdict reached about its predecessor; on the idle axis it would shut down exactly the replacement runtime. The record restore on a declined reset is conditioned on the same identity, which covers both reasons for a decline: a session that is merely busy is the one whose claim was released, so the claim goes back, while a key that changed hands must not have a claim invented for its new holder. On the orphan axis the answer is then re-asserted against the current live set. That re-assert must be the LAST read of the live set before `reset`, which is why it sits after the probe rather than before it: two awaits separate the scan from the act, the lock release and the probe's off-loop task-store read, and a slot can reopen in either window. Everything between the re-assert and `reset` is synchronous by requirement, so a check placed any earlier reopens the window it exists to close and a session the user has just resumed loses its runtime. Fail-closed has one accepted residual: a probe that cannot answer keeps every candidate it is asked about, on the idle sweep, the orphan axis and the RSS recycle alike, so a probe broken system-wide holds every reap until it recovers. That is the right answer for the sessions -- reaping on a probe that cannot see the children is the hazard the guard exists to prevent. When the probe RAISES, its visibility is a WARNING bounded by `SessionCleanup.PROBE_FAILURE_WARN_INTERVAL_SECS` (at most one line per interval across all keys, `CleanupState.probe_failure_warned_at`), with every failure keeping its traceback at debug, so a persistent break stays visible in the log without a line per candidate per tick. An unreadable task store does not raise to this wrapper: `subagents_attached_async` absorbs it and answers "attached" (`taskq_bridge.UNKNOWN_PENDING`), so that cause surfaces only as the per-key "still has sub-agent work - left running" INFO line, not as the bounded WARNING. While no live set has been published at all (`active_dashboard_slots is None`) the axis expires nothing, so a build with no dashboard keeps the idle timer as its only reaper. The policy is **re-read every tick**, not frozen at loop start: `_adopt_idle_policy()` runs at the top of the loop and again before each sleep, taking `session.timeout_secs` and `session.watchdog_rss_max_mb` off the manager's current `_cfg` (which the config watcher keeps current) and re-applying the same bounds the loader does — the 60s floor, the `0` = sweep-disabled sentinel, and the non-negative-int coercion of the RSS ceiling — plus a `MAX_TICK_INTERVAL_SECS` = 300s **ceiling on the derived interval itself**. The ceiling exists because one tick drives the idle-expiry hook AND every housekeeping sweep below it, so deriving the cadence from `timeout_secs` alone coupled the sweeps to a setting about something else and coupled it backwards: `timeout_secs=86400` gave an `86400 // 6` four-hour tick while DISABLING idle expiry (`timeout_secs=0`) gave 300s, so asking for long-lived sessions bought slower orphan cleanup than switching the idle sweep off. Capping cannot expire a session early — `_expire_idle_hook` passes `state.idle_timeout`, so the timeout still decides WHEN a session is stale and the interval only decides how often the question is asked — and it is a ceiling, not a floor, so sub-300s intervals are untouched. The sleep between sweeps is chopped into waits of at most `POLICY_REFRESH_SECS` (60s); each wake re-adopts the policy and, when the interval moved, re-anchors the next sweep to the last sweep plus the new interval, so a shortened timeout pulls the sweep forward within one refresh cadence rather than waiting out the old interval. Elapsed time is accounted from the waits the loop issued, not the wall clock, so the cadence is a property of the loop alone. A transition is logged ONCE, not per tick: `CleanupState.idle_policy_source` holds the `(timeout, rss_max)` pair the policy was last derived from and the log fires only when that pair moves. - **Session Watchdog** (`watchdog.py`): `SessionCleanup` owns the cleanup-loop state and delegates named periodic behaviours to a `SessionWatchdog` — a stateless sequential dispatcher over `CleanupHook(name, run)` entries (`tick()` isolates a hook failure with a debug-level backstop only, never promoting the severity of errors the lifted inline blocks swallowed). The hooks are assembled through the `SessionManager` facade so existing monkeypatch seams remain observable: `idle_expiry`, `orphan_mcp`, `reap_agent_scopes`, `rss_threshold`, `stuck_turn`, and `bg_drain_reap`. `SessionCleanup._cleanup_loop` then directly coordinates the session-root, sandbox-artifact, session-pid-mapping, member-pid-binding, shell-audit-log, bytecode-cache, periodic tracked-PID, and untracked-MCP sweeps. - **Runtime reconciler** (`runtime_reconcile.py`): its kill arm is bounded by `session.reconcile_max_kills`, re-read from the live config on every tick. The field's ceiling equals its default, so it can only lower the shipped budget and never raise it; at `0` the arm observes, publishing the same `unowned_alive` / `owned_dead` reading and auditing each candidate it would have signalled without sending a signal. The arm's own contract — both directions, the conjunction of conditions, and the published counters — is specified in [runtime-ownership.md](runtime-ownership.md), not here. - **Reaping abandoned agent scopes** (`session_scope_reap.py`, Linux/systemd only): each agent session runs inside a transient `systemd-run --user --scope` under a per-instance child of `kirocrew-agents.slice` (`sandbox._agents_slice_name`). `--scope` GCs a transient unit only after its process exits, so a hard gateway kill or restart strands the whole tree: the leader dies, the `launcher`/`kiro-cli`/ `kiro-cli-chat` + MCP children reparent to the systemd user manager, and nothing reaps the scope — the idle/RSS watchdog iterates only `_sessions`, the PID sweeps know only tracked roots, and `session_pid._is_untracked_managed_agent_orphan` is report-only. The reaper reconciles the cgroup tree (the only authority on what this instance leaked) against the live registry. It runs on every cleanup tick via the `reap_agent_scopes` hook — never on the gateway boot path (`AUTOSDE.yaml` `no-new-work-on-gateway-boot-path`: an orphan sweep whose cost scales with leaked state must not delay `KIROCREW_READY`), so the first tick after a restart is what picks up a previous gateway's strays; the hook gathers the live provider/pool/in-flight PID set (so a scope containing any live tree is never touched) and the reaper requires a complete tracked-PID snapshot from the `session_pid` files. An unreadable or malformed snapshot aborts that tick before any scope is scanned. A scope is reclaimed only when ALL hold: (i) no member PID is tracked or a live provider; (ii-a) AT LEAST ONE member has positive agent-runtime argv identity — the generated launcher, an exact argv0 basename of `kiro-cli`, `kiro-cli-chat`, `claude-agent-acp`, or `claude`, or a marked MCP launcher — OR EVERY member is a marked toolbox sandbox credential helper (an argv0 ending `/sandbox/creds_agent` carrying a `--session-id` argv token, version-independent because the toolbox version sits above `sandbox/` in the path), which is a scope with nothing but the helper left in it: the helper serves one runtime and routinely outlives it, and each holds tens of threads while `pids.current` counts tasks. It is deliberately not one of the existential identities, since one helper must not authorize stopping a sibling; either arm is the scope-wide stop authorization; (ii-b) EVERY member is this install's own — it carries the `KIROCREW_SPAWNED` marker, or its `ppid` chain reaches a marker-bearing member without leaving the scope's member set (ownership is by tree: env-clearing grandchildren such as `chrome-headless` renderers under a playwright `node` daemon carry no marker, and that leaked daemon tree is the multi-GB survivor users report); an unreadable `environ` fails closed to "not reclaimable"; (iii) the group leader is dead OR the scope's `ActiveEnterTimestampMonotonic` predates this gateway's boot stamp; and (iv) the scope is older than the module's 600-second grace floor. Before any stop the reaper surveys the runtime leases and tenancy claims and refuses the whole scope if any member is leased or claimed, then commits a teardown barrier for every member (a member that gained a tenant since the survey fails its commit and abandons the scope for the next pass). Reclaim is `systemctl --user stop `, then a fallback SIGTERM → grace → SIGKILL that re-reads `cgroup.procs`, opens a pidfd, requires a post-pin `cgroup.procs` read to retain that PID in the same scope, and signals through that pidfd. A member attributed before the stop is signalled on identity (same pid, same stat start ticks read after the pin), so an env-cleared child whose marked parent died to SIGTERM and was reparented still gets the SIGKILL; any other member must pass fresh tree ownership, and one that cannot is skipped with a logged reason. A recycled PID can therefore never redirect a signal; a host without pidfd support leaves the member untouched. The 3 s grace and a 2 s settle after SIGKILL (a killed task stays listed until its exit completes) each end as soon as the members signalled in that rung have left `cgroup.procs`. `pid <= 1` and the gateway's own PID are never signalled, and each scope decision emits a SEL `agent_scope_reap` event with outcome `completed`, `refused` or `failed`. Only THIS install's per-instance child slice is enumerated: a degraded instance token (no per-instance child) is treated as "nothing to reap here" rather than reaching into a co-resident gateway's scopes. A no-op off Linux or without cgroup v2 delegation (`sandbox._probe_cgroup_scope`). Marker inheritance by itself never authorizes a scope-wide stop, so an intentional detached server left after its agent runtime exits is preserved — including when a credential helper survives beside it, because that helper authorizes a stop only where it is the whole remaining scope. The accepted fail-closed residual is that a scope whose runtime-anchor members have all died is never reclaimed, even if every survivor still has the marker or is an attributable env-cleared descendant; old skipped scopes are summarized at INFO by stable reason category, making that residual operator-visible. Each member's `/proc//stat` is read as BYTES through `platform_compat.read_proc_stat`; it, `environ` and `cmdline` are read lazily and at most once per pid per evaluation, and the reclaim reuses those reads (a scope rejected at (i) with an active-enter stamp reads no stat): `comm` is whatever a process named itself through `prctl(PR_SET_NAME)`, and a text read raises on a name that is not UTF-8. The gateway's own boot stamp is read the same way, so a gateway whose `comm` is not UTF-8 still has a stamp and the predates-boot arm stays available to it. `systemctl` output is decoded with `errors="replace"`, since a localized diagnostic in a legacy locale is not UTF-8. One scope whose evaluation or reclaim RAISES costs that scope alone: an evaluation error is skipped as category `error`, and a reclaim that raises is audited by what it left behind (`completed` when the scope is empty, else `failed`, category `reclaim_error`); both count as old, so the INFO summary counts them by category on every tick, and the unit is named in a WARNING once per scope, phase and exception type, repeated at most hourly while it keeps failing and re-armed by the next clean check. An `AssertionError` is never absorbed. Residuals: a read that BLOCKS (an `environ` or `cmdline` read waiting on a process's mmap lock behind a hung mount) still stalls the sweep; and under `/proc` `hidepid`, a live group leader outside the scope whose stat cannot be read is taken as dead. Stale `session_pid_.txt`/`.sig` files are separately pruned by `_prune_stale_session_pid_files` (below); the reaper adds no second deletion path. - **Stuck-turn reporting** (`_stuck_turn_check`, threshold `_STUCK_TURN_REPORT_SECS` = 300s, not configurable): reports a turn whose consumer has stopped pulling events. Exists because the per-turn watchdog in `acp-client.md` cannot report on itself — it is the `TimeoutError` arm of an async generator, so a consumer awaiting inside its own `async for` body freezes the generator and that arm never runs again for the turn, which is why such a turn emits no stall WARNING at all. This loop has its own timer and no dependency on any consumer. Considers only sessions whose semaphore is held (the only in-flight signal at this layer), reads `parked_for_secs()` / `parked_since` / `awaiting_permission` duck-typed off `provider._handle` so any transport growing those accessors is covered, and latches on the park's monotonic start so a park outliving the tick is reported once rather than every pass. **Detection only**, deliberately: a turn awaiting a human is excluded because `agent.tool_approval_timeout_secs` already bounds that wait; ending a live turn stays with the in-band path that owns the terminal-event seam and the non-lethal continue-nudge; and what the park is blocked on is not knowable from here. Logs at WARNING and fires the optional `on_stuck_turn(key, parked_secs)` callback — a seam so a surface that can reach the user decides what to do, keeping the session layer free of any dashboard import. Swallows its own errors like its sibling hooks. The reasoning behind putting this check here rather than in the read loop — the placement criterion, what a hook may honestly read at this layer, and how out-of-band action stays clear of the in-band recovery path — is recorded in `../../architecture/design-notes/tool-stall-watchdog-placement.md`. - **RSS-threshold recycle** (`_rss_threshold_check`, config `session.watchdog_rss_max_mb`, default 0 via `DEFAULT_WATCHDOG_RSS_MAX_MB`; 0 disables): recycles non-busy sessions whose `/proc` process-tree RSS (MiB) exceeds the ceiling. Skips persistent (`_PERSISTENT_KEYS`) and `channel:`-prefixed keys — the same protected set as the idle sweep — and any session whose turn is in flight. A parent with attached sub-agent work — running or queued children, or a completion delivery still landing — is never recycled by the ceiling: with session sharing on those children run on the parent's runtime after its own turn ended, so the check consults `CleanupDeps.has_attached_subagents` (installed by `chat_utils.wire_session_subagent_probe()` from both `server.py` start paths via `SessionManager.set_subagent_probe`, built over the shared attached-children predicate) right before `reset`, and a probe that raises counts as attached. The same wrapper asks one further question before that coroutine probe: whether a completion injection is in flight (`CleanupDeps.has_pending_injection`), so the ceiling cannot recycle a runtime an already-committed turn is about to write into either. The recycle then asks that counter AGAIN once the probe returns, as the last statement before `reset`: the wrapper's read happens before the probe suspends, so an injection starting inside that await would otherwise be invisible on this path, and neither `skip_if_busy` nor `expect_session` can see it -- an injecting session holds no semaphore, and the injected turn resolves to the same object. That predicate is a COROUTINE (`chat_utils.subagents_attached_async`): its queued half reads the task store and this sweep runs on the gateway loop, so `set_subagent_probe` accepts an awaitable answer and the cleanup boundary awaits it; a sync probe (a double, a build with no queue) still answers straight away. The `/proc` parent→child map is built ONCE per tick off-loop (`_build_child_map` on the maintenance executor) and shared across candidate trees (`_rss_mb_from_tree`); resident pages are summed across the tree and converted to MiB once at the end. The tree RSS is measured once per distinct runtime pid per tick (`rss_by_pid`), since co-tenants share one tree, and at most one SUCCESSFUL reset happens per runtime pid per tick: the pid joins `recycled_pids` only after `reset` returns True, so a declined co-tenant does not spend the budget. Each successful recycle is remembered per key for `RSS_REBOUND_WINDOW_SECS` (24h); the next ceiling crossing by that key inside the window logs one `RSS recycle rebound` WARNING with the ceiling, the current and pre-recycle RSS and the elapsed time — a sign that `session.watchdog_rss_max_mb` sits below the workload's steady footprint. Measurement happens off-lock, so the victim's session object is captured at collection time and handed to `reset(expect_session=..., skip_if_busy=True)`, which re-verifies identity + not-busy atomically under the lock; a recycle that actually happened logs a warning, bumps `Stats().inc_session_cleaned()`, and fires the recycle callback (`set_recycle_callback` — mirrors the compact callback; wired by `dashboard/state.wire_session_recycle_callback()` from both `server.py` start paths to post a user-visible "session recycled" notice into `dashboard:` slots, tagged `meta={"kind": "compaction"}` so the [OPTIONS:] backward scan skips it). Idle/orphan sweeps do NOT fire the recycle callback. Linux sums the `/proc` tree; Windows uses `platform_compat.proc_rss_tree_mb_for_pid`; macOS sums each process's footprint over libproc's live child lists (`platform_compat.darwin_footprint_tree_mb`). Any other host reads 0 and logs one warning that the ceiling cannot measure. On macOS the background runtime's 1536 MiB fallback ceiling applies too. - **Harness background work** (`CleanupDeps.provider_background_launch`, `HARNESS_BACKGROUND_WORK_HOLD_SECS` = 3600 s, `HARNESS_BACKGROUND_WORK_HARD_CEILING_FACTOR` = 2): a free semaphore only proves Kiro Crew's own prompt returned. Claude Code runs a backgrounded Bash command or a Workflow in the session's process tree after the prompt answers `end_turn`, and neither the semaphore nor the sub-agent probe can see it. claude-agent-acp reports the launch on the launching call's PostToolUse `tool_call_update`, as `_meta.claudeCode.toolResponse` carrying `backgroundTaskId` (Bash) or `status: "async_launched"` with a `taskId` (Workflow); `_dispatch.parse_background_launch` reads exactly those structured fields, and skips an `Agent`/`Task` launch because the adapter holds the prompt open until such a sub-agent settles. Those two shapes were captured from claude-agent-acp 0.84.0 and Kiro Crew does not pin the adapter, so a rename would silently stop the hold. `_dispatch.unrecognised_background_launch` is the tripwire: a `toolResponse` from a non-held tool whose key, or string `status`, contains `background` or `async` (case-insensitive) while the parse recognised nothing is logged at INFO once per `BackgroundLaunchRecord` (`take_drift`), naming the offending keys. A rename that drops both words (`bgTaskId`) is not caught. Only `AcpClient` — the transport that serves claude, the one harness whose adapter stamps the marker — keeps a per-session `BackgroundLaunchRecord` today (the `AcpSessionHandle` runtime serves only backends that never stamp `_meta.claudeCode`), never reset per turn, exposed as `background_launch()` -> `(seconds since, description)` through the provider chain. Nothing the adapter sends a client without its AIR extension marks the END of that work: a live capture shows a quiet run sends nothing between `end_turn` and the model waking to report it. So the hold is bounded by time. The RSS recycle, as its last synchronous read before `reset`, and the idle sweep, as a post-await re-judge on both axes, keep a session whose newest launch is inside the hold. Past it the ceiling applies again and the recycle notice stays the plain memory-limit reason: letting the recycle through past the hold presumes the launched work finished, and the record is never cleared while the process lives, so naming a launch from hours ago would be permanent noise. The record IS cleared by `AcpClient._reset_state` — the work dies with the process whose harness started it, so a respawn on the same client must not inherit the dead tree's hold. The hold cannot override memory without bound: every launch refreshes its clock, so a session that keeps launching work would renew it forever. Inside the hold the RSS recycle therefore still proceeds when the tree exceeds `rss_max_mb * HARNESS_BACKGROUND_WORK_HARD_CEILING_FACTOR` (2x — observed real workflow trees ran 2398-2641 MB against a 1536 MB ceiling, so the hold must survive those while still cutting off a runaway), and the notice still names the work. The idle sweep is not memory-driven, so its hold has no such ceiling. The probe is not fail-closed: a missing or unreadable answer is "nothing launched". ## APIs | Method | Purpose | |--------|---------| | `start_pool(blocking=True)` | Pre-spawn warm + background sessions. `blocking=False` for non-blocking mode. | | `get_or_create(key, agent=None, channel_id=None, approval_policy="", model=None, cwd=None, extra_env=None, speculative=False, speculative_resume=False, wait_if_busy=True, start_priority=BACKGROUND, **factory_kwargs)` | Returns `(LLMProvider, is_new, resumed)`. `start_priority` orders a cold start in the start queues; only a caller a person is waiting on passes FOREGROUND ([acp-client](acp-client.md) § Session-start gate). Uses warm pool for new sessions (default agent only). Sessions with a resume mapping skip warm pool (cold start needed for `session/load`). A `reasoning_effort_override` is applied post-claim via `provider.change_effort` (updating `_effort_per_model` and the `cli.json` overlay write) rather than bypassing the warm pool, recovering pool-hit startup latency. Every decision is counted via `_record_pool_decision` (`kirocrew.session.pool.decision`) with the single disqualifying reason, so the pool's hit rate and the frequency of the `bypass_resume` case are observable. Non-default agents skip warm pool and resolve their model by precedence via `_model_fallback()` — caller model > per-agent pin > global default: `model=None` (defer to kiro's agent-JSON resolution) only when the agent pins its own model, otherwise the global default, unless that default is the `"auto"` sentinel (also `None`). The per-agent pin is resolved off the event loop via `run_in_executor` using `_resolve_named_agent_model`; blank agents inherit the global, and `kirocrew` is excluded (tracks the global). `approval_policy` is persisted on the new `_Session` — callers (e.g. subagent) pass parent policy so the session inherits it. `speculative=True` (eager spawn) pre-creates ahead of a real first turn: the one-shot `_Session.first_turn` observation — a single three-member `FirstTurnState` enum (`NOTHING_ARMED` / `FRESH` / `RESUMED`), so a resume marker on an already-claimed session is unrepresentable rather than forbidden by convention — is registered ARMED (`FRESH`) and never consumed by speculative callers, and a resumable key raises `SpeculativeResumeRefused` — unless `speculative_resume=True` (resume prefetch) opts in, in which case the speculative creator performs the `session/load` and registers the observation as `RESUMED` when the load restored the transcript. A load the provider would replace with a fresh session plus replay (Tool Search on, kiro backend, direct dashboard key — `resume_takes_tool_search_replay`, providers.md → Native-resume compatibility) never reaches this call speculatively: `chat_runner._eager_spawn` reads that predicate first and leaves the slot to its first turn, because such a load can only come back `resumed=False` and be refused after a full spawn. The observation is consumed in one read-then-clear by the first real claimant under the per-session semaphore (fast path and won-race path alike), with the returned booleans derived from it at the return boundary — so that turn observes `(is_new=True, resumed=True)` exactly as if it had resumed itself, preserving its history-injection decision. | | `check_context_usage(key, provider)` | Returns %. Triggers compaction at configured threshold (default 70%), warns one `CONTEXT_WARN_MARGIN_PCT` below it. | | `compact_if_needed(key)` | Awaitable twin of the `check_context_usage` trigger for callers that must not start their next turn while a compaction is pending (the task runner's between-steps check, #4686). Same gates in the same order — both entry points consume the shared `_compaction_gate_decision` ladder, the single owner of the gate order (its docstring documents each rung) — then AWAITS `_compact_session`. Returns the outcome: `"absent"`, `"reset"` (the settled verdict on the prior attempt was ineffective-and-still-critical and the promoted escalation reset the session here, awaited), `"cc_managed"` (checked before the threshold, mirroring `check_context_usage`), `"below_threshold"`, `"compact_unsupported"` (the provider names a backend outside `ACP_BACKENDS_COMPACT`, so no `/compact` is dispatched and no semaphore is taken — checked AFTER the threshold so a declined backend keeps its per-turn usage log, #7812), `"unconfirmed"`, `"in_progress"`, `"cooldown"`, `"ok"`, `"busy"`, `"recycled"`, `"failed"`. A `"busy"` decline means a turn holds the semaphore — the caller leaves the session alone and retries later, never falls back to a direct `provider.compact()`. | | `record_success(key)` / `record_failure(key)` | Circuit breaker tracking. | | `release(key)` | Release per-session semaphore (must call in `finally`). | | `cancel_current(key, *, wait_ack_timeout=0.0)` | Cancel in-flight operation without destroying session. Returns `CancelOutcome`. Default `wait_ack_timeout=0.0` preserves fire-and-forget behavior for internal callers (taskrunner, subagent, llm_helpers). | | `stop_turn(key, *, force=False, preserve_queue=False, on_soft=None, on_hard=None)` | Cooperative stop with kill fallback. Returns `StopOutcome` (`"soft"`, `"hard"`, `"idle"`, or `"compacting"`). A cooperative stop on a session whose own automatic `/compact` turn holds it answers `"compacting"` BEFORE recording the Stop or clearing anything: cancelling that turn would fail the compaction and recycle the session. Otherwise records the Stop, clears the queue unless `preserve_queue`, then sends `session/cancel` and waits up to `agent.soft_stop_budget_secs`; falls back to `reset()` + eager respawn on timeout or error. `force=True` is never declined: it skips cancel and goes straight to hard kill. `on_soft`/`on_hard` callbacks fire before return. Callers with side effects of their own (a queue clear, a pending-file unlink, a task pop) probe `session_lifecycle.compaction_in_flight` first and run them only after an outcome other than `"compacting"`. | | `reset(key, *, expect_session=None, skip_if_busy=False, skip_if_injecting=False, refuse_only_on_active_turn=False, clear_conversation=False, ends_conversation=False, scope=None)` | Kill session; returns `bool` (True iff a session was actually torn down). Does NOT delete session map entry (kiro-cli file persists for future resume). Optional guards evaluated atomically under the lock with the pop, used by the RSS-recycle watchdog: `expect_session` only resets if that exact session object still occupies the key (guards against recycling a reset+recreated session on a stale off-lock RSS reading); `skip_if_busy` skips when the current session's semaphore is held so a live stream is never cut mid-turn. `clear_conversation=True` additionally clears the native resume sid in the SAME event-loop tick as the pop (entry + channel bindings survive, as in `_recycle_held`) — used by the still-critical post-compaction escalation so the overflowed conversation is not reloaded, without a delayed clear ever erasing a racing successor's sid. Queued follow-ups survive a recycling reset: in the same lock hold as the pop, every entry not marked in the session's `cancelled` set is moved off the popped queue and parked (`session_lifecycle._park_queue`, bounded by `PARKED_QUEUE_MAX` / `PARKED_QUEUE_TOTAL_MAX`, files kept), and once the teardown ends the reset starts a successor under the popped session's own identity (`allocation_identity`, shared with `session_compaction._restart_held`: agent, approval policy, cwd, bound channel, selected model and crew member; a caller's `extra_env` is not recorded on a session and is not carried), so it registers, adopts them at the queue head (`adopt_parked_queue`) and its release wakes their channels' drains. The respawn re-arms the successor's first-turn observation (`FRESH`, or `RESUMED` after a native resume) from the flags its start returned before releasing it, so the first queued turn, not the respawn, sees `is_new`; a failed native resume therefore still replays Kiro Crew history into that turn. A default-argument respawn would be wrong here: a later claim takes a live session as-is, so it would answer every later message as the default agent. Without this a failure-path reset (`AcpPromptBusy` in `slack/handler.py`, the circuit breaker) silently dropped messages sent while the failing turn ran, because the failed turn's end-of-turn `dequeue` finds no live session. Cancelled entries stay on the popped queue and are unlinked as before. `ends_conversation=True` keeps dropping the queue: the caller is ending the conversation. | | `discard_conversation(key, *, replay=True, skip_if_busy=False, refuse_only_on_active_turn=False)` | Kill session AND clear only the resume sid (`SessionMap.clear_sid`) — the map ENTRY survives, preserving Slack thread/channel linkage and the reverse thread→session index. The cleared sid is stashed as `discarded_sid` in the entry, so the discard is diagnosable and manually reversible (the native conversation persists on disk; only the pointer is dropped). Every path that empties `sid` in place records what it dropped, through one shared `_stash_and_clear_sid` — this discard, the provider switch, the startup prune and the per-read stale repair — because a history reader answers from that field and cannot tell which path wrote it, so a field written by only some of them holds a genuine id that is not the latest one. The next turn cold-starts a fresh native conversation instead of `session/load`-ing the old one. Used by the poisoned-conversation escalation in `chat_runner` (canary-verified backend rejection of a specific persisted conversation), by typed `IMAGE_FORMAT_UNSUPPORTED` recovery when a dashboard turn supplied no new attachment (the native history retains unsupported image bytes while the bounded Kiro Crew replay is text-only), and by the Slack / Discord / Telegram `/compact` failure recovery: the conversation is unusable but the session's channel identity must persist. This is the shape every HOUSEKEEPING teardown takes — `SessionMap.prune` refuses to delete an entry carrying a channel binding, and `_recycle_held` clears the sid for the same reason. Only an explicit user action (`destroy`) may remove a channel identity. Sits between `reset` (sid kept, resume expected) and `remove` (entry deleted, no resume). | | `remove(key)` | Shut down a session but PRESERVE the session map entry — the kiro-cli session files remain on disk, so a future `get_or_create` restores the conversation losslessly via `session/load`. For revivable teardown (tab close, agent switch, idle kill). Permanent deletion is `destroy(key)`. | | `forget_conversation_if_sid(key, expected_sid)` | Compare-and-delete of the conversation's session-map entry inside one `_MAP_LOCK` section: deletes only while the entry still names `expected_sid`. Returns `(removed, current_sid)`. | | `destroy(key)` | Permanently remove the live provider, compaction override, and session-map entry. The map entry is deleted in the yield-free registry-pop span before the awaited end metric, so a dashboard slot cannot adopt the predecessor binding during that metric write. | | `destroy_if(key, expected_generation, should_destroy, *, preserve_autocompact_override=False)` | Conditional permanent removal for the monotonic canonical-key generation captured by `session_generation(key)`. Under the manager lock it requires no current allocation/claim reservation, requires the generation to remain equal, requires the current session semaphore to be idle, then evaluates the synchronous slot-owner predicate immediately before the registry pop and yield-free session-map delete. Any reservation, generation mismatch (including absent→successor→absent ABA), busy session, false predicate, or predicate exception leaves provider, override, and map untouched. History deletion passes `preserve_autocompact_override=True` because another process can claim the same logical transcript; ordinary conditional and unconditional destroy keep clearing the old override. Returns whether destruction occurred. | | `remove_if_unclaimed(key)` | Conditional `remove` for the resume-prefetch TTL: removes the session only if the one-shot `first_turn` observation is still armed (not `NOTHING_ARMED` — no real turn claimed it) AND the per-session semaphore is unheld, checked atomically under the manager lock. Preserves the session map (mirrors `remove`'s revivable shape), so the next focus or first message resumes normally. Returns `True` iff a session was removed. A claimant handed the session object but not yet holding the semaphore loses benignly: its re-validate fails and it cold-starts. | | `close_all(drain_timeout=None)` | Pre-shutdown **drain** of in-flight turns (via `drain_active_turns`), then save all active session mappings, shut down every session, and drain the warm pool. `drain_timeout` bounds that drain (`None` = full default budget); a caller wrapping `close_all()` in its own hard deadline (Slack's restart wraps it in `wait_for(..., 5s)`) passes a smaller budget (e.g. `2.0`) so the kill path still fits inside the deadline. A cancel that fires mid-drain (outer deadline) **propagates** (CancelledError is deliberately not caught) so the caller's hard deadline stays honest; recovery of a still-held native-session lock is the next-startup orphan reaper's job. | | `drain_active_turns(timeout=None)` | Best-effort co-operative drain that brings in-flight prompts to a safe turn boundary **before** teardown, so kiro-cli closes its native turn and releases its session lock (`~/.kiro/sessions/cli/.json`) on the subsequent SIGTERM — otherwise the next gateway's `session/load` hits "active in another process" and the slot returns empty completions (the Make-Live empty-response incident, #200). For each registered session with an **unfinished** turn (native turn-done not yet acked — independent of cancel state, so an already-cancelled-but-not-acked turn is still drained), it issues a graceful `session/cancel` and waits (bounded) for the ack; a turn already cancelled (`cancel()` → `"no_turn"`) is waited on directly via `wait_turn_done`. The whole operation is bounded by `timeout` (`None` → `_DRAIN_ACTIVE_TURNS_TIMEOUT_SECS`, default 5.0s; internal cap is `timeout+1.0`); on timeout it logs and returns so the caller falls through to the SIGTERM-first kill path — never hangs teardown, never raises. `timeout <= 0` disables the drain. Returns the count of unfinished turns (observability/tests). Only registered user sessions are drained; the warm pool holds never-prompted processes. | | `pause_turn_admission_for_update()` | Atomically pauses new turn admission under the session registry lock by setting the existing `_closing` gate and recording `update_pause_owned`. Returns `False` when real shutdown already owns `_closing`, or when a gateway stop is already signalled (`shutdown_event`, checked under the lock), so update logic cannot mask or replace shutdown and no installer starts into one. The pause covers both new `get_or_create` calls and already-issued leases reaching `begin_turn`. Channel callbacks claim a synchronous `reserve_inbound_callback()` before card or command handling; task-backed callbacks hold it until their handler task ends, while inline pollers scope it to one dispatch so the poll task does not keep updates busy forever. Admitted callbacks and pre-start client `_handler_tasks` are census-visible, while a claim refused after the pause writes the existing resend-notice route before any pre-turn side effect. Subagent, direct cron script/command, TaskRunner, and dynamic-workflow launchers read the same `admission_closed` state immediately before registering work, with no suspension before registration: a launch either registers before the pause and appears in the busy count, or is rejected after it. The subagent pump's two re-registrations read it too — the window refill (`_refill_apply`) and a resume reservation (`resume_reserve`) — because both would grow the busy count AFTER the census read even though the store already accepted that work: a hydrated row would only be refused by the spawn gate, and a resumed run would run on in a process about to be replaced. The gateway treats this boundary as apply-safe only when provider/Slack turns, every live dashboard `slot.task` (including pre-provider and remote-relay turns), shielded refusal writers, and all background workloads are idle. Subagent idleness is lifecycle-based rather than slot-based: queued/running work, unexpected-cancel recovery, shielded terminal reports, accepted follow-up watchers, one-shot orphan reconciliation, and detached state writers must all settle; the perpetual maintenance reaper is excluded. After apply, it drains callback tasks and refusal writers again; a timeout defers only the restart, reopens admission, and retries in five minutes without reapplying. A successful drain is followed immediately by `fence_update_restart()`, making any later refusal write synchronous through session teardown and the final drain, with no `await` between that drain and re-exec. Mandatory updates use a target-keyed ten-minute grace only for escalation logging; they still defer behind every active turn and background workload indefinitely. Automatic update preparation never calls `drain_active_turns()` and never cancels user work. On the managed-venv wheel route the build runs beside the live venv with admission OPEN, and the pause brackets only the restart into the promoted tree; the git and policy routes are as above. | | `resume_turn_admission_after_update()` | Releases `_closing` only when `update_pause_owned` is still true and no gateway stop is signalled (`shutdown_event`, checked under the lock); returns `True` only when admission actually reopened, and the gateway schedules its inbound-spool replay only then. `close_all()` revokes that ownership under the same lock before draining, so an update failure racing real shutdown cannot reopen admission. Used when automatic apply returns instead of replacing the process; during a shutdown (which stops the update first) the pause is kept so inbound turns keep being spooled. | | `begin_turn(key)` | **Synchronous** pre-dispatch gate against the lease-dispatch race (#200 / Codex HIGH). A caller holds the per-session semaphore *lease* from `get_or_create` through the whole turn, but the native turn only opens on the first `provider.stream(...)` iteration; the `get_or_create` `_closing` gate cannot revoke a lease already issued before `close_all` set `_closing`. Callers (dashboard `chat_runner`, Slack handler, and structured Slack/Discord monitor adapters through `TurnDriver.closing_gate`) MUST call `begin_turn` synchronously — **no `await` between it and the `async for` stream drive** — so the `_closing` read and the stream's turn registration (`AcpClient.stream_events` clears `_turn_done` before its first `await`) form one yield-free span, strictly ordered w.r.t. `close_all`'s `_closing` set: the turn is either registered before the drain snapshot (and drained) or the caller aborts. Raises `SessionClosingError` (a `RuntimeError`) when closing; the caller's `finally` releases the lease. Deliberately NOT `async`/lock-guarded (an `await` would reopen the race). | ## Live config: the watcher drives `refresh_defaults` The manager copies `session.*` and `agent.*` values out of `config.json` at construction, so a write reaches those copies only if something pushes the new value at them. The constructor registers that push on the process config watcher (`live.subscribe("session", "agent", "watchdog", "agents", "workspaces", "default_workspace", callback=self._on_config_change, name="SessionManager")`, kept on `self._config_sub`; the watcher holds the bound method weakly, so a manager a test or a provider reload discards drops out of the registry on its own). Every writer — the dashboard, `kirocrew config set`, `$EDITOR` — lands in the same applier, so none of the behaviour below depends on which one wrote. No gateway restart is needed for any of it. `_on_config_change` first fails closed like the owned appliers: while any of its six sections is degraded (a section the loader discarded holds DEFAULTS for it; the whole-config marker alone does not gate it — see the config spec) it raises `ConfigDeferred` for the changed paths and keeps what is in force, because adopting defaults would rebuild the provider factory on the default model and backend and drain the warm pool; the watcher retries each tick until the document validates. Otherwise it does three things, in order: - **Adopt the new config as `_cfg`, always.** Most fields under these prefixes are read off `_cfg` (or fresh from the loader) at their point of use, so the adoption IS the whole apply — `agent.soft_stop_budget_secs` per stop, and the cleanup loop's idle policy below. The adoption happens under `_lock`. - **Route a factory-bound default through `refresh_defaults(cfg=change.new)`.** `_FACTORY_CONFIG_PATHS` lists the paths a rebuilt provider factory or a re-derived warm pool is the only way to honour: `agent.model`, `agent.reasoning_effort`, `agent.acp_backend`, `agent.role_efforts`, `agent.tool_search{,_min_pct,_min_tokens}`, `agent.sandbox`, `agent.sandbox_allow_no_isolation`, `agent.sandbox_allow_unsandboxed_exec`, `agent.member_acp_backend`, `agent.default_agent`, `session.pool_size` / `pool_agent` / `pool_ttl_secs`, `workspaces` and `default_workspace` (the last three because the warm pool captures its agent and cwd). `refresh_defaults` is the **live-session-preserving** path — it rebuilds the factory, re-derives the pool and drains the warm pool, but never touches a registered session, so in-flight turns keep running and only NEW sessions see the new defaults. `reload_provider_factory` (which retires sessions built by the old factory) is not on this path. `session.eager_spawn` is deliberately absent: it is read live per spawn in `chat_runner`, so draining the pool for it would be pure churn. - **Re-clamp the watchdog windows on every live handle** when the change touches `watchdog.*`, `agent.chat_turn_timeout_secs`, or any `agents..watchdog_*` key — see [acp-client.md](acp-client.md) for the fan-out and why it re-runs the loader per handle instead of copying seconds across. `refresh_defaults(cfg=None)` takes an **already-loaded** config: the watcher hands in the one it just loaded so the apply needs no second read, and `None` (the request-handler callers) loads off-loop inside the fill lock, as before. It re-derives the whole warm-pool shape, not just the factory — `_pool_size` (clamped to `_MAX_POOL`, floored at 0), `_pool_agent` (`session_pool.pool_kiro_agent`: `session.pool_agent`, falling back to `agent.default_agent` when blank; a blank name or a config alias under `agents` is resolved through `resolve_agent_identity` to the kiro agent a session asks the pool for, e.g. `kirocrew` for the default, while any other name is kept as written), `_pool_ttl_secs` (floored at 0) and `_pool_cwd` — using the same clamps `WarmSessionPool._state_from_owner` applies at construction, so a hot value can never be a raw copy that bypasses them. `pool_ttl_secs` in particular was re-adopted by no path before. `_pool_cwd` is `default_project_dir()`, which is resolved from `default_workspace` and `workspaces`, so both are factory paths and both prefixes are subscribed: a workspace edit re-derives the pool instead of leaving a cwd-less subagent in the previous directory. The resolution reads the config file and stats the workspace directory, so it runs in a worker thread before `_lock` is taken, like the load itself. Two more reads on this area follow config without a restart: - `AcpRuntime._session_start_budget` prefers `live.snapshot()` over its per-runtime memo, keeping the same builtin floor, and falls back to the memo in a process with no watcher armed. - `ContextBuilder`'s `{bot_name}` substitution reads `live.snapshot().agent.bot_name`, falling back to the value captured at construction when there is no snapshot or the live one is blank. `acp/client.py`'s `resolve_prompt_timeout` already loads config per prompt (`_effective_prompt_timeout_async`), so `agent.chat_turn_timeout_secs` needed no applier — a raised turn budget is in force on the next prompt. ## Stop Orchestration `stop_turn()` is the shared orchestration layer for every stop surface (dashboard Stop button, Slack `/kirocrew stop`, transport stop verbs). Sequence: 0. Decline a cooperative stop while the session's own automatic `/compact` turn holds it (`key in _compacting`, `force=False`): return `"compacting"` before anything below runs. Cancelling that turn would fail the compaction and recycle the session, so nothing is recorded and nothing is cleared; the caller tells the user and the compaction finishes on its own. `force=True` is never declined. 1. Record the Stop: `stop_requests[key] += 1` (per folded key, on `SessionLifecycleState`). This runs BEFORE anything is awaited so the dashboard runner's end-of-turn gates -- which may run the moment the provider's cancel lands -- already see it; `stop_turn()` sets `prev_turn_cancelled` only after the ack, which is too late for them. 2. `clear_queue(key)` — queue drop is unconditional on first press (skipped with `preserve_queue=True`). Passing no ownership predicate is what makes the drop whole-session, which is what a Stop button press means. A per-sender stop verb passes one and drops only that principal's entries; see "Hard cancel: `/stop`" in `messaging.md`. 3. If `force=True`: skip cancel, go straight to hard kill (step 5). 4. Send `session/cancel` via `provider.cancel(wait_ack_timeout=budget)`: - `"acked"` → set `session.prev_turn_cancelled = True`, call `on_soft` callback, return `"soft"`. - `"no_turn"` → return `"idle"`. - `"timeout"` or `"error"` → fall through to hard kill. 5. Hard kill: `reset(key)` → fire-and-forget `_eager_respawn(key)` task → call `on_hard` callback → return `"hard"`. ### Session-scoped Stop record `SessionManager.stop_generation(key)` reads the count from step 1 (0 for a key never stopped). It exists because a channel-born dashboard slot runs its turns on the channel's session (`effective_session_key` returns `linked_session_key`), so a stop issued on the channel side reaches `stop_turn()` and the provider cancel but never the slot's own `_stop_state`. The dashboard runner snapshots the count at turn entry and its live Stop signal (`_stop_pressed()`) treats any later change as a user Stop, next to the slot's in-flight state and the slot's own `_stop_generation`; every end-of-turn continuation gate (refusal recovery, Stop-hook continuation, promise-only recovery, post-compaction continuation) reads that one signal. Lifetime: the record is keyed by session key rather than stored on the `_Session` object, so it survives the `reset()` a hard stop performs (a flag on the session would vanish with the very turn it stopped). It is popped on the teardown paths that end the key's conversation for good -- `remove()`, `remove_if_unclaimed()`, `destroy()`, and the identity-sweep retirement -- beside the sibling per-key dicts. `cancel_current()` does NOT record a stop: it is the host's own best-effort abort (queue drain, injection retry, run teardown), not a person pressing Stop, and must not suppress a continuation the way a Stop does. ### Cancelled-turn context restore `_Session.prev_turn_cancelled` is a one-shot flag with two writers. `stop_turn()` sets it only on an `acked` soft cancel (step 4 above). `cancel_owned_turn()` sets it before the provider cancel starts, so every outcome it attempts (`acked`, `timeout`, `error`, `no_turn`) leaves the flag set; see "Owner-fenced deadline cancel" below. The next prompt handler (dashboard `_run_chat`, Slack `handle_message`) reads and clears it, then calls `context.build_cancelled_turn_preamble(conversation_log, session_key)` to re-inject the cancelled user prompt and partial assistant output. This is necessary because kiro-cli discards cancelled turns from its own ACP conversation log, so the LLM has no memory of the interrupted request. ### Owner-fenced deadline cancel: `cancel_owned_turn` `SessionManager.cancel_owned_turn(key, owner_task, wait_ack_timeout=...)` is the native cancel the dashboard's wall-clock turn ceiling (`_bounded_turn`) sends before it unwinds the Python stream. It is not a Stop surface and does not go through `stop_turn()`. It returns the provider's `CancelOutcome`, or `None` when it sends nothing. Sequence: 1. Exact-owner fence: fold the key and return `None` unless the session exists and `session.turn_owner is owner_task`. A deadline left over from an earlier turn, or a turn still waiting behind another owner, cannot cancel the turn that holds the lease now. 2. Known-terminal skip: if `provider_turn_is_known_terminal(session.provider)` proves the native turn already reached its done boundary (the Python owner is only finishing transcript teardown), return `None`. No cancel is sent and replay is not armed. A bare `no_turn` is not that proof, and a provider that cannot prove it stays on the cancel path. 3. Arm replay: set `session.prev_turn_cancelled = True` before the first await. No await sits between the owner check and entering `provider.cancel`, so a successor cannot take the lease first, and an ack cannot release the lease before the interrupted context is marked. The flag is set for every outcome step 4 can return (`acked`, `timeout`, `error`, `no_turn`). This is fail-closed on purpose: on an unacknowledged outcome the dashboard warns that the session must not resume until idle, and a successor that runs anyway still gets the cancelled prompt and partial output re-injected. 4. Send `session/cancel` via `provider.cancel(wait_ack_timeout=...)` on the exact provider object read in step 1, and return its outcome. Unlike `stop_turn()`, no outcome leads to `reset()`, a hard kill, or an eager respawn; an unacknowledged cancel leaves the runtime as it is. The caller bounds the whole call at `max(0.1, agent.soft_stop_budget_secs) + 0.5` seconds and maps an overrun to `timeout` and an exception to `error`. A Stop or shutdown that arrives while that bounded window is open waits for it to settle, and the turn then ends with the timeout error rather than a plain cancellation. ### Interrupted-turn context restore kiro-cli appends a prompt to `.jsonl` only once the model has answered it, so a turn cut off by the serving process dying (a gateway restart, a crash, a recycled runtime) is never written, and `session/load` restores the conversation without it. A natively resumed session gets no Kiro Crew replay, so the dashboard's Resume press (`_MANUAL_RESUME_MSG`) and the automatic connection-lost and session-busy recoveries (`_CONN_RECOVER_MSG`, `_BUSY_RECOVER_MSG`) would ask the model to finish a request it cannot see. When either runs on a session that THIS turn resumed natively (`is_new` and the provider has history), `_run_chat` prepends `context.build_interrupted_turn_preamble(slot.messages, current=...)` to the final prompt AFTER the egress scrub (its markers are registered in `_STRUCTURAL_MARKER_RES`, so a copy planted in transcript or tool text is neutralized; the restored payload is scrubbed by the builder): the newest turn opener before the resume row (a user, nudge or sub-agent row, or an `inject` whose kind is in `dashboard.state.TURN_OPENING_INJECT_KINDS` -- a cron, app or synthesis delivery; a recovery inject such as an earlier Resume press is walked past) plus the assistant text it had streamed, bracketed `[INTERRUPTED TURN …]` / `[END INTERRUPTED TURN]`. Only a `role=user` opener is framed as the user's most recent request; a nudge, sub-agent, cron, app (`mcp_app`) or synthesis opener is labelled as an automated delivery of that kind (`replay._interrupted_opener_kind`), never as something the user typed. A cold start that replays (Tool Search on a dashboard chat, a failed load) already carries the turn and gets no preamble. A session whose ONLY turn was interrupted is handled one layer down: `SessionMap.get` prunes a sid whose `.jsonl` holds no turn, so that start replays instead of loading. ### Edit rewind context boundary Dashboard Edit + Send replaces the ACP session and rebuilds context from the retained canonical history. The discarded suffix is excluded from session replay, stop recovery, and persisted-history context; stable memory, rules, skills, and project context remain available. The native conversation is discarded before the retained history rewrite, and the cleared resume pointer is flushed durably before the rewrite is committed, so a gateway restart cannot resurrect the discarded native session. If the rewrite fails -- or the slot was concurrently rebound to another transcript while it was in flight -- Edit + Send returns a 503 and restores the dashboard slot, but it cannot restore the discarded native session; a later turn cold-starts from the original persisted history instead of resuming it. App-authenticated requests may rewind only a slot's own dashboard session: a channel-linked slot is refused, because its effective session is a conversation the app does not own. **`edit-resend` is the same boundary, not a lighter one.** It truncates and persists history exactly as rewind does, so it runs the same three-step sequence — discard the native conversation, flush the cleared resume sid, then rewrite the retained history — and refuses with `edit_resend_prepare_failed` / `edit_resend_session_busy` / `edit_resend_save_failed` / `edit_resend_slot_rebound` rather than reporting success on a boundary that did not land. Its own error vocabulary is deliberate: a client must be able to tell which endpoint refused without string-matching a sentence. **A busy SESSION is not the same question as a busy slot.** `slot.running` tracks only that slot's own task, while `discard_conversation` is a full teardown that also releases the shared sub-agent runtime. So `edit-resend` applies the same guard the sibling `reset-conversation` teardown applies before the same call, with the same code: `slot_subagents_running` (409) via the shared `chat_utils.subagents_attached_async` predicate, because the parent turn ends before its children do. The predicate fails closed on an unreadable probe: unknown children are not zero children. `skip_if_busy=True` on the discard remains the atomic backstop for a turn admitted after these guards answered False. Eight properties are load-bearing on this boundary, and each fails toward the permissive answer if dropped. `edit-resend` carries all eight; the bullets name each one `rewind` does not carry, so nobody reads it as already shared: - **The edited window is prepared on a copy, and the copy is SEVERED.** `copy.copy` is shallow, so reassigning `messages` alone leaves `_queue`, `_pending`, `_question_pending`, `_on_question_retired`, and `event` aliased to the live slot — and `_ChatSlot.append` writes through four of them. An un-severed copy therefore publishes the edited row to the live stream reader and announces the live question cards as retired *before* any refusal path can run, leaving a phantom row and a card-less "needs input" behind for an edit the server rejected. The commit is the one place the prepared `_pending`, `_question_pending`, `event` state and the retirement announcement become live. - **The slot is reserved before the awaits.** `slot.running` derives from `slot.task` and the send path is not serialized on `slot._lock`, so a send arriving while a durable boundary is pending would otherwise see an idle slot and dispatch a competing turn that the commit then erases. The reservation publishes a dispatch task that runs the turn only on commit; on abort it hands a send it diverted to the queue to the canonical successor dispatch, so nothing is stranded. An entry queued *before* the reservation keeps its own trigger. - **The commit re-checks its target on every path**, success included, through ONE predicate so no path can check a different subset. Three axes move independently across the awaits: the **transcript key** (a cron or workflow injection re-links the slot; the snapshot froze the old routing, so the save's own `expected_history_key` guard cannot see the live slot move and only this loop-side check can), the **slot object** (a close-and-recreate under the same name is a different conversation that leaves the transcript key unchanged, so only object identity catches it), and the **dispatch reservation** (if something else has taken `slot.task`, committing would run this handler's turn alongside whatever now owns the slot — two concurrent turns writing one window). Any of the three refuses with a retryable 503. - **The commit predicate does not cover the durable write; the CLOSE path orders it instead.** Every call to the commit predicate above happens AFTER the save has settled, so for the file it is a post-mortem: it can refuse the live commit and the dispatch, and it cannot un-truncate a transcript that has already been replaced. The truncated window is frozen against ONE slot incarnation, and a same-name close-and-recreate can be published inside the executor wait, after every check the handler can run on the loop. Such a recreate resuming the same conversation leaves the transcript key unchanged, so `expected_history_key` waves it through. `chat_persistence._save_slot_to_history` does accept `expected_slot_name` and re-reads `state._slots` under the transcript lock before writing, and every truncating rewrite save passes it beside `expected_history_key` — regenerate, switch-variant, edit-resend, rewind and the fork's pending-rewrite flush (`test_every_truncating_rewrite_pins_slot_identity_at_the_commit_boundary`). That check is necessary but not sufficient, and no caller should be read as closing the race: the harm does not need the replacement published before the check, only that it READS the file after the write commits. The save holds the per-session lock across its whole read-modify-write, so a replacement published during the write waits and then hydrates from the truncated content. Closing it needs slot publication ordered against the persistence commit, and that ordering exists at the retraction that hands a reused name to a replacement: **`close_slot`**. It fences the slot with `begin_close`, waits for the slot's registered **guarded writes** — a truncating save, meaning one carrying an authorized `expected_history_key` — and only then pops the name. The registry `slot._guarded_history_writes` holds the writes' executor FUTURES, not a count, because a count released in an awaiter's `finally` reads zero the moment that awaiter is cancelled while its worker thread runs on to the rename. `save_slot_off_loop` registers every guarded write it dispatches; `chat_rewind.api_chat_slot_rewind` calls `register_guarded_history_write` on its own `asyncio.to_thread` save, which is the one truncating write that does not go through that helper. A truncating save is one that rewrites the window rather than appending to it: it passes an explicit `messages` snapshot, or `rewrite=True`. EVERY one of them carries an authorized transcript key, the fork's pending-rewrite flush (`chat_fork`) included — without the key a truncating save skips the fence, the registration AND the in-lock routing re-read, leaving it less ordered against a retraction than the ones that are fenced. The enumeration is read from the syntax tree by a test, so the population cannot grow quietly. A registered write must never be cancelled by its own handler. Cancelling it makes the task DONE, the done callback drops it from the registry, and the worker thread runs on to the rename with nothing left to order against it -- the same hole, reached from the cancellation path instead of the dispatch one. So every await on a registered task is shielded, including the awaits in a cancellation drain, and each drain is bounded (`_SAVE_DRAIN_ATTEMPTS`) so a cancel storm cannot spin. A task that never settles stays pending and stays registered, which is what the close needs. The periodic writer honours the same fence: `flush_slot_now` returns without writing while `slot.is_closing`, and leaves `_dirty` armed. A fenced slot is still the occupant of its name until the pop, so the five-second pass still visits it, and that write is not a registered guarded write, so the retraction's wait cannot see it — a tick landing between fence and pop would overwrite whatever adopts the name next. A close that completes persists the window itself through its archival save; a close that is abandoned lets the next pass write the owed value. That fence is necessary but NOT sufficient, for the same reason the whole ordering exists: it is read on the flush executor thread while the retraction runs on the loop, and the write reaches the transcript lock only after the snapshot, routing and retention stretch. So the periodic save also passes `expected_slot_name`, which decides INSIDE the lock with no await before the write. It matters more here than elsewhere: a periodic save is a full metadata rebuild and does not request the `rows_only` deferral that keeps another holder's folder, title and tag. That in-lock refusal keeps the write OWED (`_keep_owed_after_refusal`, kept with the save's other refusal rules in `dashboard/slot_persistence/write_guards.py`), for the reason its own docstring gives: `flush_slot_now` clears `_dirty` on any return that did not raise, so a guard refusing without re-arming erases the only in-memory witness of an edit it never wrote. An in-place edit has no substitute witness — the popped slot is outside the registry the periodic pass walks, and its window length matches disk. A refusal is a DEFERRAL, not a loss: it marks the slot `_dirty` so the periodic flush retries the write once the fence is down. That matters because the metadata-only mutations — a recreate's title, a folder filing, a tag, a pin — save with `force=True`, set no `_dirty` of their own, and publish on the acknowledged edit without reading the return; and a close can raise the fence and then leave the slot live, since it refuses on a breached wait and the sweep raises and releases the fence around a deferral. Without the arming, an edit landing in that window is dropped and the old value returns after a restart. The pair is decidable in BOTH directions because each dispatch seam re-reads the fence with no suspension between the read and the registration: either the fence is up and the write refuses, or the write is registered and the close waits for it. A write that outlasts the 5-second ceiling **refuses the close** (`SlotCloseError` code `history_write_running`); the tab stays open and retryable rather than having its name retracted with a thread still writing. `close_slot` is the only retraction that WAITS, because it is the only one obliged to finish — the person asked for it. The fence itself is a DEPTH, not a flag, because two retractions can overlap on one slot: the close suspends inside its wait and the sweep can reach the same slot meanwhile. With a shared flag, whichever finished first cleared the fence for both, and the other's remaining awaits ran unfenced — which is the window the fence exists to close, since the dispatch-seam re-reads read exactly this value. Counting means each holder releases only its own acquisition; `cancel_close` floors at zero so an unmatched release cannot make a later `begin_close` read as not-closing. The sweep additionally skips a slot that is already closing, BEFORE raising its own fence, since after that it could not tell its acquisition from the other holder's. The bulk stale-slot sweep pops each slot itself and is fenced too, but it DEFERS instead of waiting. Staleness is judged from last recorded activity, and a truncating save's HANDLER can be in flight far longer than its worker thread — a rewind on a conversation nobody has touched for days is admitted, awaits its native teardown, and only then dispatches — so a stale slot can carry a guarded write. A pending guarded write is itself proof the tab is not idle, whatever its timestamps say, so the sweep reads the registry SYNCHRONOUSLY behind the fence and leaves such a slot for the next sweep, reporting it in `failed`. Waiting there would be worse than useless: the handlers that produce a guarded write publish a task on the same slot in the same breath, so a wait would hold the sweep open exactly while the tab is being edited and the pop after it would cancel that turn. Deferring removes that window by construction, and the fence is what makes the synchronous read sound: with it up no new guarded write can be dispatched, so an empty reading stays empty through the pop. There is deliberately NO await between that fence and that pop. The sweep also releases the fence itself where a failed archive restores the slot, since it has no `close_slot` wrapper to do that for it. Residuals, deliberately: `_materialise_slot_from_history` publishes a slot rebuilt from disk rather than retracting a live one, so it keeps only the narrower in-lock `expected_slot_name` re-read. The history-delete pop in `handlers/sessions.py` needs no wait for a different reason: `delete_session` unlinks under the SAME `_locked` region the save takes, so the **delete-won guard** refuses the write outright instead of resurrecting the transcript. Making registration automatic at the `_save_slot_to_history` layer, so a new truncating caller cannot opt out, and making `pop_slot` an enforced chokepoint rather than a facade, are tracked in issue #12090. - **The periodic dirty-slot flush is excluded for the whole rewrite.** Because the live slot keeps the full window until the commit, a flush tick can snapshot that stale window, block behind the rewrite on the per-session history lock, and then write the snapshot back on top — restoring every message the rewrite just discarded. `edit-resend` therefore saves through `chat_persistence.save_slot_off_loop` (with `expected_history_key` and `expected_slot_name`, and `best_effort=False` so a failure reaches its 503 rather than being swallowed and re-armed as a dirty retry) instead of a bare `asyncio.to_thread`. That helper raises `slot._metadata_persist_inflight` around the write and lowers it in a `finally` — the flag `flush_slot_now` already honours to keep the unpinned periodic writer off a slot with a guarded write pending. Shielding the *wrapper* rather than the inner future is what keeps the exclusion held: a cancellation reaching the shield leaves the coroutine running, so its `finally` cannot release the flag early. That counter and `_guarded_history_writes` are different mechanisms with different consumers and are not interchangeable: the counter answers `flush_slot_now`, whose question is whether the periodic writer may start, and an awaiter's cancellation lowering it early is the correct answer there. The close's question is whether the worker THREAD has returned, which only the future can answer. - **The cancellation drain survives REPEATED cancellation.** The worker thread cannot be interrupted, so once the rewrite starts it lands whether the handler lives or not; the handler therefore has to learn the outcome and commit to match. `CancelledError` is a `BaseException`, so a second cancellation — a gateway shutdown reaching a handler already unwinding from a client disconnect — is not absorbed by an `except Exception` and a bare `await` on the save task abandons a landed rewrite. `edit-resend` re-shields the drain a bounded number of times (`_SAVE_DRAIN_ATTEMPTS`) and reads the outcome off the **settled** task rather than awaiting it, so a cancel landing between the two cannot lose it. Giving up leaves the live slot untouched, which is the safe half of the desync. `rewind` does the same (`chat_rewind._SAVE_DRAIN_ATTEMPTS`), reading the outcome off the settled task. - **A row that arrives during the boundary is carried, not replaced away.** `workflow_inject` and `cron_inject` append through `append_and_surface` / `slot.append` on the event loop and take no `slot._lock`, so a completion landing mid-boundary reaches the live window while the boundary holds the lock. A wholesale `slot.messages = prospective_slot.messages` drops it, and the rewrite save cannot restore it because a rewrite deliberately skips the cross-process-append scan (`collect_foreign = not rewrite`) — leaving the row in neither the window nor the file. `edit-resend` therefore carries arrived rows (identified by row object, since the window front can be trimmed and a restore-path row has no `meta.mid`) onto the committed window and pending queue. Appending them after the prospective window is the correct order, not merely a convenient one: `monotonic_transcript_ts` only ever moves a row forward, so an arrived row can never be stamped *earlier* than the edited one — but it can be stamped **identically**, because on a coarse clock (Windows ticks in ~15.6 ms steps) both appends read the same instant, and list order is what separates that tie. Its question map is **retired in place** rather than adopted or intersected: the commit deletes exactly the ids the edit retired, so a card answered during the boundary stays retired (the answer pops it from the live dict) and a card raised during the boundary survives (an intersection against the frozen copy would erase it). A carried row reaches disk the ordinary way — the commit sets `_dirty`, so the next periodic flush writes the merged window — and deliberately **not** through a second guarded save after the commit: no await may sit between the commit and the dispatch release (see the next bullet), so that write is not available without paying a worse failure. **`rewind` applies the same delta commit**: it carries arrived rows in both the window and the pending queue, drops a row the client already drained rather than requeueing it, and retires question ids in place. The identity sets keep the pre-await rows RETAINED, because an `id()` is only an identity while something holds a reference and a cap trim would otherwise let a freed row's address be reused by an arrival. The other rewrite-save callers (`regenerate`, `fork`) are **deliberately still open** to the injected-row loss; closing them belongs with the shared boundary contract rather than one endpoint, and the fixed subset is exactly `rewind` and `edit-resend`. - **The commit and the dispatch release are separated by no await.** Once the live slot has adopted the truncated window, the reserved dispatch is armed and only `dispatch_ready.set()` in the handler's `finally` is left to run. An await in that gap lets a cron or workflow completion rebind the slot, and the released dispatch then runs the edited prompt against ANOTHER conversation — and the commit-target fence cannot rescue it, because refusing after the live slot has adopted the truncated window would leave a truncation with no turn. So post-commit work is left to the next periodic flush rather than awaited here. **`rewind` still awaits its orphan-session cleanup in that gap**, so it remains exposed. - **App ownership is authorized through the shared gate** (`slot_ownership.deny_app_slot_session_access`, plus `_reauthorize_after_await` across the body-read await), because discarding a native conversation is a destructive capability. It authorizes the `_app` binding, the effective SESSION key, and the TRANSCRIPT key, so a channel-linked slot and an unbound channel-origin slot are both covered by one check rather than a per-endpoint link test. Denials are 404, not 403 — indistinguishable from a missing slot (anti-enumeration); the real reason is in the SEL audit log. ### Eager Respawn After a hard kill, `_eager_respawn(key)` calls `get_or_create(key)` in a background task so the next user message finds a warm session. On failure, logs at debug and does nothing — the next message triggers `get_or_create` again via the normal path. ## Session Resume (SessionMap) Persistent mapping of `session_key → kiro_session_id` stored at `~/.kiro/crew/session_map.json`. Enables `session/load` to restore full kiro-cli conversation history when a session is recycled. **Only long-lived conversational sessions are mapped.** Stateless sessions (cron, subagent, taskrunner, channel, secretary, side, heartbeat/background, `wf-author:` workflow authoring, and `wf-pool:` warm workflow-pool workers) are excluded from resume mapping. The prefixed keys match `_STATELESS_PREFIXES`; `_bg` and `_hb` are excluded by the sibling persistent-key check in the same stateless classification, not by a prefix. A `wf-author:` session is also explicitly destroyed after each authoring attempt, which shuts down its provider, removes its registry entry, and deletes any stale map entry; stateless classification prevents resume lookup or persistence during acquisition. The `wf-pool:` prefix keeps per-run pooled workers (workflows/agent_pool.py) from persisting a session_map entry or resuming a prior transcript — their hard-reset fallback must hand the next task a clean session, never a `session/load` replay of the previous task's conversation. The `side:` prefix is included so `/side` conversations never resume across KiroCrew restarts — each cold-start triggers `is_first_turn=True` in `build_side_message` which re-seeds the parent snapshot + accumulated side history. The `thread:` prefix (a reply thread on a crewmate chat message, [history](history.md#reply-threads-on-crewmate-chat-messages-dashboardchat_threadspy)) is included for the same reason: `build_thread_message` re-seeds the whole envelope on a cold session. **Lifecycle:** - `get_or_create()`: looks up mapping → if found and `.json` file exists, sets `resume_session_id` on the ACP client and skips warm pool. After `ensure_ready()`, saves the new `session_key → session_id` mapping. - `reset()`: does NOT delete mapping — the kiro-cli session file persists on disk. Next `get_or_create` will try `session/load`. - `remove()`: PRESERVES the mapping — revivable teardown (tab close, agent switch, idle kill); the next `get_or_create` resumes. - `destroy()` / `destroy_if()`: delete the mapping — permanent removal, no resume expected. - `close_all()`: saves all active mappings before killing processes. - `start_pool()`: prunes stale entries (files deleted by kiro-cli GC). ### Asking for a fresh conversation on a slot that stays open `POST /api/chat/slots/{slot}/reset-conversation` drops one slot's resume pointer through the LIVE manager (`discard_conversation`), so its next turn cold-starts instead of `session/load`-ing the accumulated conversation. The slot stays open, the transcript stays on disk, and the map ENTRY survives with its channel linkage. This closes a gap rather than adding a capability: resume is key-driven and a slot key is stable by design, which is correct for a tab reopened later and wrong once a long-lived conversation has drifted, filled up, or outlived what it was about. The only reachable way to break the link was `DELETE /api/sessions/{key}`, which destroys the record in order to reset the pointer — so "start over" and "erase this" were the same button. **`replay` is what the caller means by "fresh", and it has to be asked for.** Clearing the sid stops the provider resuming its own conversation — and "the provider has no history" is precisely the condition that makes the next cold start rebuild one from `conversation_log` as a `[CONVERSATION HISTORY]` block (`chat_runner`, injected OUTSIDE the capped session context). So the two mechanisms work against each other by construction: the caller discards the conversation and the next turn is handed a reconstruction of it. Measured on one app-owned session, that replay was 80,359 characters — 76% of the first turn's injected context, and most of what discarding the conversation was meant to reclaim. `discard_conversation(key, replay=False)` records a ONE-SHOT suppression, consumed at the replay gate inside the cold-start branch so a warm turn cannot spend it, and the route threads it from an optional `replay` field on the request body. The default is `True`, which keeps every existing caller and the dashboard's own copy ("Conversation history is preserved — your next message starts a fresh process") true. Only the RE-INJECTION is suppressed: the transcript is untouched, so the conversation stays readable in the dashboard and on disk. The flag cannot live on the session object the way `needs_context_reinjection` does, because `discard_conversation` POPS that session — the decision is made by the turn that tears the conversation down and acted on by the next turn, which builds a new one. It is therefore a manager-level set, process-scoped on purpose: a gateway restart also cold-starts the session, but there the replay is legitimate, since nobody asked for a fresh conversation and re-anchoring is what that surface has always done. Every teardown path that already clears the compaction cooldown clears it too (`reset`, `remove`, `retire_kiro_identity_sessions`, `remove_if_unclaimed`, `destroy`, and `close_all`), because slot keys ARE reused and a leaked flag would starve the NEXT holder of that key of its re-anchor. Slot-key reuse is also why the dashboard close/teardown path re-checks identity after it pops the slot. Both `close_slot` (shared by `api_chat_slot_delete` and session-control's `close_target`) and `api_chat_slots_cleanup` pop `name` out of `state._slots` and then run several AWAITS — cancel the task, `save_slot_off_loop(..., closed=True)`, `sessions.remove(_history_key_for(name))`. Across that window a concurrent same-key recreate (a `POST /api/chat`, or the `session_close` MCP verb) can mint a REPLACEMENT slot under the same key, reusing the same history key and the same session. Because both sites still hold the popped object (`slot` / `removed`), a synchronous, race-free discriminator is available, and there are TWO of them because the destructive steps do not all answer to the same owner. `_slot_still_ours(state, name, )` is the KEY-scoped one. It asks whether a DIFFERENT object now owns the key — an absent key is the ordinary post-pop state of every close, so `None` counts as still ours; reading it as "our object owns the key" would make the guard fire on every close and skip the very teardown it guards. It governs the two steps whose resource IS the key: `sessions.remove` (whose argument is `_history_key_for(name)`, the session an unbound replacement runs on) and the failure arms' restores (`state._slots[name] = slot` / `= removed`), which run only when the key is still free or still the popped object and so never clobber a live replacement. Cleanup's `archived` report is key-scoped too, and stays key-scoped: it names slot keys, so a key with a live holder is never listed however its transcript ended up. `_replacement_shares_transcript(state, name, )` is the TRANSCRIPT-scoped one, and it is what governs the `closed=True` save — because that save's argument is not the key. It targets `slot_history_key(slot)`, so a slot carrying a `linked_session_key` (channel-, cron- or workflow-born) writes the LINKED transcript while a replacement minted by a plain `get_or_create_slot(name)` — what `POST /api/chat` and the `session_close` verb take — is unbound and writes `dashboard:{name}`. Same key, two files, so key identity cannot decide this step: yielding the archive to a replacement that shares nothing would leave the original's own transcript with no `closed` flag, and `channel_slots._close_stands` reads an absent flag as "the user never dismissed this", so the reconcile pass resurfaces the tab that was closed. The predicate therefore compares FILE identity (`transcript_stems` on both sides) rather than key strings: `history._safe_key` folds `slack:` and the `slack_` stem onto one `.jsonl` and a pre-migration thread still resolves to its bare `thread_ts` stem, and the two errors are not symmetric — over-reporting "shared" merely declines an archive the next close will make, while under-reporting stamps `closed` on a file a live slot is writing. When the replacement DOES share the transcript, the archive and the session teardown are both skipped. The original was already popped and cancelled, so the close is effectively complete for it: `close_slot` RETURNS rather than raising `SlotCloseError`, which is what makes both its callers report success, and cleanup takes its `continue` without counting the key archived. When it does NOT share the transcript the archive runs normally on the original's own file and only the key-scoped steps yield. The `note_slot_closed` tombstone (below) still fires before these awaits for the reconcile reader; it is NOT the vehicle for either guard, which are pure post-pop re-checks confined to the two teardown paths. What both guards cover is the WIDE window, not the durable write. `save_slot_off_loop` reaches its commit through the process-wide default executor, so a recreate can still land between the last synchronous check and the in-lock write, leaving `closed=True` on a key a live replacement holds. That residual is what an unguarded close carries as well, and the row is the same one a plain sequential close-then-reopen of a reused key produces: `closed`/`closed_at` are in `SLOT_OWNED_META_KEYS`, so the replacement's next full save drops them, and `api_chat_slot_resume` compensates a stale flag with an in-lock compare-and-clear (`clear_closed(..., only_if_closed_before=...)`). Closing it AT the commit needs an ownership predicate evaluated inside `_locked(history_key)` on the write AND on the resume's read-then-clear — a durable-metadata contract change rather than a loop-side ordering one, so it is deliberately not what these two teardown paths do. Yielding to a replacement carries four obligations, and they exist because the state a close compensates is not all scoped the same way. - **Key-scoped state moves with the key.** `state._restricted_keys` holds `dashboard:{name}` — a SESSION KEY, not a slot identity — and `_is_restricted_session` tests that set BEFORE it looks at the slot. So every exit of either teardown owes one postcondition, which is what `_resettle_restricted_key(state, name)` IS: the key is marked iff the slot currently AT `name` is restricted, an absent key counting as unrestricted. All six exits go through it rather than through a bare `discard` — the ordinary close (where the key is gone, so the marker drops and the next holder is not starved), and every exit that hands the key to a replacement (where it is re-derived from the REPLACEMENT). Re-derived, never blindly discarded: a replacement that is itself restricted keeps the marker, since dropping it is the fail-OPEN direction. Skipping it on a hand-over gives a persistent replacement an incognito original's 403 on every memory, artifact and mcp-apps call for as long as that tab lives. - **Slot-scoped compensation is coupled to the restore.** The failure arms owe the ORIGINAL two rollbacks, and both are conditional on the original getting its key back — not on the original merely existing. The nudge loop already is, through `_restore_slot_nudge_loop`'s own `state.get_slot(name) is slot` admission check. `notify_slot_close_undone` is coupled the same way rather than gated on `slot._app` alone: with a replacement on the key there is no tab to put back, so the dismissal DID happen for the original, and resuming the app's worker re-arms an autonomous crew whose `slot_key` its watchdog resolves with a bare `state.get_slot(...)` and no ownership test — handing the auto-approve grant, and then an unbounded nudge clock, to the user-owned replacement. Leaving the pause is the same answer the pre-save guard gives from the identical state, and it is a first-class visible one (a `paused_reason` row with a resume control). The bulk archive has no sibling here: it deliberately never calls `notify_slot_closed`, so it has no app dismissal to take back. - **The original's unpersisted content is owed to its transcript, not to the original's slot object.** A hand-over exit stops referencing the popped slot, and `_flush_dirty_slots` iterates exactly `state._slots`, so an unreferenced slot has NO retry path: anything past its last commit — `messages[_disk_window_len:]`, plus a note the bulk path is still holding in `_deferred_notes` — would simply cease to exist from this gateway's own delivery paths. (Since #4093 a held note also has a durable copy in the slot's metadata line, so a dropped hold is re-delivered after the NEXT restart rather than lost outright — but deferring an acknowledged note to a hypothetical future restart is not delivery, so the hand-over drain below is still what honors it in this lifetime. One version-skew caveat: the retirement invariant holds only for gateways that stamp `meta.noteId` on delivered rows. An older gateway carries `deferred_notes` as unowned metadata, its flush stamps no id and its save retires nothing, so a downgrade-deliver-reupgrade cycle replays already-delivered notes as duplicates — bounded harm, and the chosen at-least-once direction, but the invariant silently does not hold across versions.) The pre-save exits need no store failure to reach it either; they return before the save is attempted, in a window that opens while a turn is in flight. So every hand-over exit routes through `_persist_handover_tail(state, name, slot)`, which flushes held notes into the window and writes it with **`closed=False`**. Those rows belong on the ORIGINAL's own transcript whether or not the replacement shares it; what must not happen is the archive flag and the session teardown, not the write. The target is `slot_history_key(slot)` and never a derived `dashboard:` — the forced save resolves its own target the same way and REFUSES a write whose `expected_history_key` names a different transcript, so the derived form would make the drain a silent no-op for every cron-, channel- or workflow-linked slot and would name a row-less file in the failure log. The write is non-destructive against the replacement's rows in both directions: `_save_slot_to_history`'s foreign-append scan carries through every on-disk line the saved window does not represent, so rows a replacement already committed survive. The METADATA line is a different matter and is not the drain's to move, so the write is `rows_only`. `_save_slot_to_history` is otherwise authoritative for `SLOT_OWNED_META_KEYS` and REBUILDS that line from whichever slot it is handed, so a default save here would revert a title, folder, tag set or pin the replacement had already published onto the shared transcript (`POST /api/chat/slots` persists a folder and a pinned title at birth) — silently undoing an acknowledged edit, and for a tab nobody types in again undoing it for good, so the next restart resurrects the dismissed tab's name and filing. `rows_only` keeps the on-disk value for every one of those fields and narrows this write's ownership to `ROWS_ONLY_OWNED_META_KEYS`: the file's identity and accounting, which every writer maintains and which the save carries forward from disk anyway. The set it defers, `ROWS_ONLY_DEFERRED_META_KEYS`, is named in full rather than derived as `SLOT_OWNED_META_KEYS - ROWS_ONLY_OWNED_META_KEYS`, because that difference under-approximates: the slot save also writes fields that DESCRIBE an owned one without being owned themselves, and a title's provenance and refresh budget (`title_origin`, `title_refresh_mark`) travel WITH the title rather than with the writer. Deferring the title while keeping those commits a line matching neither slot — read back beside another slot's title they either unlock the background refresh on a name the user typed by hand or lock a generated name out of refresh permanently — so they are deferred with it. `created_by` and `origin` are the same shape with AUTHORIZATION rather than presentation behind them, so they are deferred too: `created_by` is what session-control's member ownership boundary reads and is meaningless without the `mode` deferred beside it, and `origin` must round-trip with the deferred `app` because the pair decides `slots:user` visibility and the unattended approval window. Both describe the SLOT, so on a transcript with a live holder the holder's are the true ones — and deferring them fails CLOSED on a line that carries neither, since an absent `created_by` denies and an absent `origin` restores to the sentinel the rehydrate paths already treat as unattributed. The conversation's own MONOTONE once-flags (`auto_tagged`, `human_seen`, `channel_origin`, `channel_folder_filed`) are set and never cleared, so two writers on one transcript cannot disagree about them in a way that outlives the pair; they stay as written. Deferring to disk is deliberately not the same as deriving the line from the replacement — a recreate that published nothing has no metadata to protect, and re-deriving from it would ERASE a real title and filing the two slots' shared conversation has; leaving the line alone is what gets both directions right with one write. **The deferral is conditional on there actually being another writer to defer to**, and the line's `tab_id` — minted per slot object, stamped by every save — is the evidence: the flag holds fields back only on a line ANOTHER slot published, and a line this slot published itself (or no line at all) takes the ordinary rebuild. Without that test the flag would cost the original its own uncommitted metadata: a rename, re-file, tag or pin is acknowledged the instant it lands in memory and persists on a later `_dirty` flush, and the drain runs past the pop, where no flush will ever visit that slot again. The two errors are not symmetric, so unprovable ownership defers: a deferred edit of this slot's was never committed, while a rebuild over a live holder's line reverts what it already published and nothing rewrites that for a replacement nobody types in again. `closed`/`closed_at` are deferred on the same asymmetry, and the drain is open-shaped without being un-closing: on another holder's line a `closed` flag is that holder's own DISMISSAL, so erasing it would resurface a tab the user put away — permanently, since the holder that wrote it is popped too — and re-arm the channel reconciler on it, while leaving a stale flag costs nothing durable, because the live holder owns those keys on its next full save. The only path that clears a stale flag from outside the holder is the resume route, and it clears one only when it can prove the close predates its own boundary (`clear_closed(..., only_if_closed_before=...)`, compared inside the store's lock), for exactly this reason: an unconditional clear reopens a replacement the user closed. A rows-only save carries no such boundary, so clearing a stale flag is instead the job of the `tab_id` fallback above: on a line THIS slot published there is no other holder's dismissal to lose, so the ordinary rebuild runs and the open-shaped write erases it. The failure arms take the same route in place of the restore they skip: a store that rejected the `closed=True` write can still accept the next one, and a lock lost to the recreate is exactly that case. - **A drain that fails is reported, not swallowed.** `_persist_handover_tail` returns a named result: `rows_committed` says whether rows were owed and reached disk, `prompts_lost` counts the durable-eligible queued prompts whose only copy dies with the popped slot. The result is a tuple and therefore always truthy, so callers read `rows_committed` rather than testing the result itself — and every caller honours it, because this frame is the last reference to those rows, so nothing will retry and nothing else will ever report them. Both PRE-SAVE hand-over exits therefore turn a failed commit into their path's own failure: `close_slot` raises `SlotCloseError(code="history_save_failed")` (the same code as an ordinary failed archive — from the caller's side it is one thing, a close whose history write did not land) and cleanup adds the key to `failed`. There is nothing to roll back on either exit, so the report IS the whole remedy; a 200 there would claim durability the close does not have. The two FAILURE-arm drains need no branch of their own: those arms already end in `SlotCloseError` / `failed.append(name)`, so a lost tail reaches the caller regardless, and the drain only decides whether the rows survived. Every failure is also logged with the exact row count, which is the only report anything in the process can still make about the rows themselves. A non-zero `prompts_lost` additionally posts a dashboard notification naming the slot and the count — never the prompt text, which may belong to a restricted session — because the gateway log is not reachable by the person whose words were dropped. Survival is judged by who writes the durable line next, not by the slot's own persistence signature: a live transcript-sharing holder rebuilds the shared line on its every full save, so an owed entry survives a hand-over only when that holder's own queue carries it (a rehydrated holder restores the entries as queue cards; a fresh recreate does not), while with no live sharing holder the line is at rest and answers directly. An owed entry with no durable future is counted lost on every exit — including the no-write one. Three properties the route holds, each of which fails silently if broken: - The key comes from `effective_session_key(slot)`, never a derived `dashboard:`: a channel-born slot's turns run on the channel's session, and the derived form yields a key no session ever had — the clear finds nothing and the call still reports success. - The teardown also ends the parent's sub-agent runs; see "Parent end ends the children" below for why that rides the release rather than being called here. - `discard_conversation`, never `destroy`: the entry carries the Slack thread/channel linkage and the reverse index built from it. - It is nonetheless a FULL teardown (provider shutdown plus `release_subagent_runtime`), so it takes the same guards the sibling `reload` route does, through the same shared helpers rather than a third policy: `_app_cancel_denied` on the resolved SESSION key, `provider.has_active_turn()`, `slot.running`, and `_subagents_attached_response`. Each protects work invisible from outside — a turn on the session with no dashboard task behind it (an inbound channel message, which `slot.running` cannot see), a turn mid-write, and children still running after their parent's turn ended. The four probes above are best-effort fast paths; the authoritative guard is the fifth, `discard_conversation(..., skip_if_busy=True)`, which probes the per-session SEMAPHORE atomically with the session pop and refuses with the same `turn_in_flight` 409. It closes the edge the fast paths share: a turn holding the semaphore before its prompt is in flight is invisible to `has_active_turn`. This is the same contract the sibling reload route rests on, so the two teardowns keep one notion of "busy". Of the route's refusal paths, only the atomic one emits a SEL `denied` record — it is the sole refusal that occurs after the route has committed to the teardown; the fast-path 409s are pre-checks and stay unlogged, as they are on the sibling. Authorization is two layers. The per-slot checkpoint (`slot_ownership_middleware`, [App Kit platform §13](app-kit-platform.md)) admits an app only as the slot's owner on its own session and transcript, before the handler runs. `_app_cancel_denied` then tests the resolved target key the caller will actually act on. Ownership alone is not enough, and that is load-bearing: `get_or_create_slot` resolves `linked_session_key` from the session map for a name shaped like a channel stem, so an app that names a live channel thread ends up OWNING a slot bound to a conversation it has no claim on, and slot ownership by itself would let it wipe that channel conversation's resume pointer. The helper runs BEFORE the 409s so a refusal cannot confirm the slot exists. Reaching the route needs `/api/chat/slots` in the app's manifest `permissions.api`, and the capability it grants is strictly smaller than the delete it already implies. The transcript is deliberately left in place, so the tab still shows earlier messages the model no longer remembers. That is the honest rendering — the record is the user's, the context was the conversation's — and it is why this is an explicit request rather than something the gateway does on its own. ### Parent end ends the children `release_subagent_runtime` IS this module's parent-end boundary, so the runs a parent owns are ended at every site that calls it. Both halves are driven here: `_snapshot_parent_children` in the same lock hold as the pop, because every await after that is a window a successor can register under the retired key in, and `_cancel_parent_children` after the provider teardown, bounded and best-effort, with the release as the backstop for whatever it does not reach. `subagent.md` describes the two verbs and why the teardown one suppresses parent delivery. Two properties of that call, each of which fails silently if broken: - **It is outside any `if session` guard.** A live provider is not what makes a call a parent end. A reset pops the session and keeps the conversation, so the tab close that follows arrives with `session is None` while the children are still running — and that is the call that ends them. `remove` guarded both the cancel and the release on a live provider, which skipped exactly the sequence the reset/remove split makes ordinary. - **The timeout bounds the parent's WAIT, not the reap.** `_cancel_parent_children` runs the verb as a task registered in `_background_tasks` and waits on `asyncio.shield(task)`, so `_CHILD_CANCEL_TIMEOUT_SECS` expiring leaves the reap running to completion. A bare `wait_for` cancels what it waits on, and this coroutine kills child processes: a long child reset would have its `_force_reap` cancelled after the marks were written and before the kills landed, leaving a write-capable child executing against a conversation that has ended. Sites: `reset` (conditionally, see below), `remove`, `destroy`, `discard_conversation`, `remove_if_unclaimed`, `retire_kiro_identity_sessions`, and `end_children_for`, which ends the children when `workflows/agent_pool` reuses a live worker for a new conversation without popping the session or touching processes. The selection is NOT complete, and deliberately so. The mark and the snapshot are both taken inside this lock hold, which is the only synchronous point available, so anything already in flight sees neither: a spawn admitted late may still START, and a report already past the delivery gate may still DELIVER into a conversation that has ended. Both are bounded by the run's own timeout. Neither closes with another recheck at one end, because any wider selection or later re-test needs an await, and an await cannot tell work belonging to the retired conversation from work a successor under the same key has just started — which needs a conversation-incarnation counter the session layer does not have. Full argument in [subagent.md](subagent.md) § RESIDUAL; tracked in #12069. The distinction is "does the CONVERSATION end", not "does the process die". `remove` is a revivable ending — the entry survives for a future `session/load` — and it still takes the children, because the conversation is over as far as the parent is concerned. `reset` is on the other side by DEFAULT. It keeps the session-map entry and its resume sid, so the next turn restores the same native conversation through `session/load`, which is why it is the verb every evict-and-retry path reaches for: a wedged prompt (`AcpPromptBusy`), a failed auto-compaction, a provider or model switch (`_reset_slot_session`, which serves the agent / model / reasoning-effort / workspace switches and the reload endpoint), an idle expiry, the channel watchdog, a task step's re-prompt. A child of a recycled session has a conversation to deliver into and is bounded by its own run timeout, so stopping it would discard live work for a conversation that is coming right back. The RSS recycle sits here too and needs no argument: `_rss_threshold_check` declines outright for a session with attached sub-agent work. But some endings reach ONLY `reset`, so `ends_conversation=True` exists to say so, and those callers are: | caller | why it ends the conversation | |---|---| | `dashboard/handlers_channel.py` — `api_channel_clear_context` | the user asked the agent to forget the conversation (`scope=all` also wipes the channel's shared buffer) | | `cron.py` — `cancel` / `_force_reap` | the job is cancelled or reaped, so its conversation is over | | `taskrunner.py` — `_cleanup_run_sessions` | cancel cleanup ends every step conversation of the run | | `workflows/agent_pool.py` — `reset` | the pool STARTS A NEW conversation on a pooled key | | `session_compaction.py` — critical reset after a failed compaction (`clear_conversation=True`) | the overflowed conversation is cleared, so it is over | The default is the recycle because that is what the overwhelming majority of `reset`'s callers are, and the two mistakes do not cost the same — but a MISSED flag costs more than an orphan, which is the number a future caller has to weigh. It arms no delivery gate either, so the child's report still reaches `_on_done`, which resolves the parent through `get_or_create` and creates a session when none is live: the conversation the caller ended re-opens, seeded with that report. The miss is the headline defect in full, not a bounded process. A wrong `True` destroys live work for a conversation that resumes next turn, which is why the default stays the recycle — but a new ending path that forgets the flag RESURRECTS, and nothing structural catches it, because a keyword is invisible to the AST ratchet. Until #12069's conversation-incarnation identity makes a forgotten flag harmless, the flag is the whole guard and the path pin below is the only thing watching it. Two exemptions from the release-site rule, each on a fact about itself: - `close_all` — gateway shutdown runs `SubagentManager.cancel_all`, which also drains follow-up watchers and announces undelivered messages. - `_retire_kiro_subagent_runtimes` — it reaps only IDLE companion runtimes, skipping any that answer `has_active_or_initializing_sessions()`, so it has no running child to end and the parent conversation continues. A ratchet in `test_session.py` reads that off the source on the AST: a method that releases a companion runtime without calling both halves fails it, the two exemptions are named there, and an exempt method that stops releasing a runtime is reported so its exemption never goes unchecked. The `ends_conversation=True` call sites are pinned separately, by path, because a structural ratchet cannot see a keyword and losing one is silent. ### Load Recovery (stale native session lock — F2) On restart / Make-Live cutover the previous gateway's kiro-cli is killed. If it died uncleanly (SIGKILL, crash, OOM, or a drain timeout), its per-session lock can stay held briefly, so the new gateway's `session/load` is rejected with an **"active in another process"** error. The dashboard's hard-stop path has a second shape of the same race: `stop_turn` resets the session and eagerly respawns it, and kiro-cli's `session/load` in the new holder creates its lock and re-reads it to confirm ownership — if the killed holder's exit handler unlinks the same path in that window the load fails with **"failed to re-read lock file ...: No such file or directory"**. Both are transient (`_RESUME_TRANSIENT_LOCK_MARKERS`: `"active in another process"`, `"re-read lock file"` — deliberately not a bare `"lock file"`, so a permanent failure such as `Permission denied` on the lock path still fails fast to Phase 2) and recovery happens at the resume chokepoint (`AcpProvider._load_session_with_retry`, `providers/acp.py`) and self-heals regardless of *why* the resume failed — it never depends on the dead holder cooperating (unlike cooperative drain), so it covers every kill mode: 1. **Phase 1 — bounded retry (lossless).** Re-issue `session/load` up to `_RESUME_MAX_ATTEMPTS` (4) times with exponential backoff (`_RESUME_BACKOFF_BASE_S` → 1s, 2s, 4s). If the lock clears, the session resumes with full native history. A genuine (non-lock) load error is **not** retried, and a dead runtime aborts the loop immediately (the caller's respawn path takes over). kiro-cli deletes `.lock` (under `kiro_sessions_dir()`) only on a clean exit, so a SIGKILLed holder's lock never clears by waiting: on the first `"active in another process"` refusal, `_unlink_dead_session_lock` reads the lock's `pid` and, only when that PID is dead (`platform_compat.pid_exists`), removes the lock and re-issues the load at once, a single time per resume. A live holder, or a lock it cannot read or parse, is left alone and the backoff proceeds. Pinned by `test/test_acp_stale_session_lock.py`. 2. **Phase 2 — fresh session + history replay (backstop).** If the lock never clears, `_start_kiro_runtime_impl` falls through to a fresh `session/new` and sets `AcpProvider._history_replay_needed`. `get_or_create` reads that flag and sets `_Session.provider_switch_replay = True`, so `build_session_replay` injects KiroCrew's `conversation_log` into the new native session on the first prompt (the same replay path used for cross-provider switches). The slot resumes seamlessly instead of returning empty completions. Observability: a successful Phase-1 recovery logs at INFO; exhausting all attempts logs a single grep-able WARNING before migrating to Phase 2. ### Lost backend session ("Session not found") A live backend can answer every prompt with `Session not found` for the backend session id a conversation is bound to. The process is up, so the dead-provider eviction never runs. `llm_helpers.acp_error_is_session_not_found` names this answer, scoped to `AcpError`. The remedy is the same on every surface: `SessionManager.reset(key)` drops the live binding and keeps the session-map entry, so the next claim spawns a fresh runtime that `session/load`s the SAME id; Load Recovery above falls back to a new session plus history replay only when that load fails. The turn is re-run once. A second loss ends on a clear error. The reset needs the surface's own session manager and the re-run needs its own output path, so each surface routes the answer into the one replace-and-run-again seam it already owns: | Surface | Seam | |---|---| | Dashboard chat | `chat_runner` AcpError arm, queued replay with Stop guards | | Slack thread | `handler._replay_abandoned`, the nested replay | | Channel agent | `_stream_task` verdict into `_recover_busy_agent` | | Cron, one agent | `_cron_callback` ACP-death reset-and-retry arm | | Cron, sequence step | one-shot re-run of the step | | Subagent completion into a parent | `_inject_with_retry`, reset and re-claim | | Subagent run | `_schedule_cancel_recovery(reason="session_not_found")` | Invariant: the turn is never re-sent once a tool ran in it, since that could repeat a side effect. `stream_and_collect` tags the `AcpError` it raises with `turn_tool_activity` (`acp_error_after_tool_activity` reads it); the cron post-token CONTINUE marks any loss after its first prompt the same way. The Slack path gates on streamed output or a tool card, the channel path on tool events, and the subagent run on `tool_count`. A gated turn still resets, so the next message reconnects. Not armed: Telegram/Discord transport dispatch, workflows, the task-queue ACP adapter and the CLI chat surface a lost session as a plain error. ### Cross-Provider Continuity Each ACP harness keeps its own native session ids, which are NOT interchangeable, and the session map records which one a sid belongs to by a per-backend label (`acp` for kiro-cli, `claude_code`, `kas`, `codex`, `opencode`, …; the `PROVIDER_LABEL_*` constants in `acp/types.py`), selected by `agent.acp_backend`. Claude is one selectable ACP harness, not a separate provider. When a user switches harness mid-session, conversation continuity is maintained via **history replay**, never via session_id translation. **Detection:** `detect_provider_switch(session_map, key, new_provider)` in `session.py` compares the stored provider against the new one. Returns True when a switch is detected (stored SID exists AND providers differ). **Behavior on switch:** 1. `resume_sid` is discarded (not passed to the new provider process) 2. `SessionMap.clear_sid(key)` removes the stale SID from persistent state 3. `_Session.provider_switch_replay = True` flags the session for replay 4. The new provider's session_id (once obtained) is saved with the correct provider label 5. On the first prompt after the switch, `chat_runner` detects the flag and injects history from `build_session_replay()` (Kiro Crew's conversation_log) 6. The flag remains armed through prompt acceptance and is settled only when the replay-bearing turn lands. ACP providers promote a deliberately deferred fresh SID before consuming the lease; non-ACP providers already published their SID during allocation and consume the lease directly. Cancelled, failed, empty, or synthetic terminals leave it armed for the next prompt. **Fresh first-turn history debt.** A fresh dashboard session has a second, separate lease, `_Session.first_turn_history_owed`, so the replay lease's SID-preservation role in `close_all` is untouched. The one-shot `first_turn` observation is spent at claim, so a first turn that assembled history and then failed with a pre-output backend 5xx would be re-queued onto the same live session reading `is_new=False` and send the message bare, with no session-resumed marker. `chat_runner` arms the lease at claim from the FRESH observation (`mark_first_turn_history_owed`, only when `is_new` and not natively resumed) and ORs it, read without clearing (`first_turn_history_owed_pending`), into `_context_is_new`, so the re-queued turn rebuilds history. `consume_first_turn_history_owed` settles it only when a turn assembled history and the provider kept it: a normal non-synthetic `end_turn`, output that streamed without a cancel or empty-response verdict, a completed mid-turn compaction, or the post-token recovery (`acp_recovery._recover_posttoken_transient`). A reset that suppressed replay and `/clear` also settle it. A cancelled, empty-response or pre-output exit leaves it armed, and a slash command that assembled nothing never settles it. **Replayed content carries no image reference.** `_replay_rows` and `_recall_rows` — the two row builders behind every history vehicle (`build_session_replay` and the thread-history fallback in `build_session_context`) — hand each row out through `kiro_crew.image_refs.strip_image_refs`, which replaces every local image reference with `[image not carried into this context]`. Markdown references go through the attachment store's own `iter_local_refs`; bare paths go through the inliner's own `_PATH_RE`, narrowed to paths outside code spans that stand alone rather than sit inside a URL query, because the inliner rewrites text only after reading a file and an unconditional substitution would corrupt a URL or a code snippet instead of scrubbing it. The grammar also admits a space or tab, since an attachment name can carry one, and that is the shape the text cannot settle: such a span has that token replaced when its last token is a path on its own, and is replaced whole only when it is a channel's own attachment line — alone on its line (list and quote markers allowed) AND ending in the `mkstemp` file name every channel's shared ingest writer mints, which is what vouches for the space before it (a temp directory under a profile like `John Smith`) — or a markdown link's angle-form destination, which can hold nothing but a path. Being alone on a line is not enough: `/var/log/app has the broken logo.png.` is a whole line a person types. No delimiter pair vouches for one either, quotes included. Otherwise the span keeps its text, so prose such as `check /var/log/app and tell me why logo.png is broken` survives intact. Every rule is lexical: the scrubber makes no filesystem call, because it runs inline on the event loop while the inliner's own probes are offloaded through `asyncio.to_thread`, and resolving a data home to settle a span would put a network stat in front of every history row. The residue is every other spaced path, a `Screen Shot 2024.png` a person typed alone on its line included: it stays as text, which the inliner can still pick up on a replayed turn while the file is there — the deliberate cost of not deleting the sentence around it. A background one-liner (`llm_helpers.run_bg_oneliner`: titles, summaries, suggestions, status cards, folder picks) never inlines at all. Its prompt is text ABOUT a session, so a path in it is quoted history, and the turn goes out with `allow_image=False` on whichever handle `get_bg_session()` returned; every layer down to `build_prompt_blocks` forwards it (see [acp-client](acp-client.md#image-support)). So no shape a caller composes (a flattened row, a ` - User: ` prefix, a JSON string) can become an image block for a text-only background model. The scrub it also runs only tidies the text. The history consolidator's turns (the consolidation itself, the skill dedupe judge and the skill merge) quote transcript rows the same way and go out the same way, through `stream_and_collect(..., allow_image=False)`. `chat_title._title_text` does not call the scrubber at all: it strips markdown images and attachment markers its own way and keeps an escaped or code-quoted `![x](…)` readable, while the scrubber's bare-path pass replaces the destination inside an escaped one (a code-quoted one survives both passes). A channel's bare path is neither, so it stays in the title prompt as text. A row's picture belonged to an earlier turn and a text vehicle cannot carry bytes, so the reference is the only thing that would arrive, and both readings of it are wrong: while the file is still readable `build_prompt_blocks` re-inlines it (a picture an earlier compaction already dropped returns at full byte cost on every later cold start), and once the file is gone the path is left in the prose next to the assistant's own earlier description of what it showed. Stripping at the row builders rather than at each consumer is what makes the guarantee hold for all three. The CURRENT turn is unaffected — it is excluded from the replay by identity, so a freshly attached image still becomes a real image block. **Same-provider resume:** unaffected. Normal `session/load` path with full native fidelity. **Audit:** A `provider_switch_detected` SEL event is emitted with both the stored and new provider names for observability. **Atomic write:** tmp file + `os.replace()` prevents corruption on crash. **Deferred flush (event loop only):** a mutation made on the event loop marks the map dirty and schedules a debounced flush task; the task serializes the map under `_MAP_LOCK` into an immutable JSON payload, then performs the tmp+rename in a worker thread — the loop never pays the file write inline, and `_data` never crosses the thread boundary. Coalescing never drops a trailing mutation (the task loops until it observes a clean map), and a per-snapshot ticket keeps a slow in-flight write from landing an older map over a newer forced one. `SessionMap.flush()` (sync contexts) and `SessionMap.aflush()` (awaited) are the deterministic durability points. `SessionMap.aclose()` is the shutdown boundary used by `SessionManager.close_all()`: it cancels and awaits the registered debounce task, preserves an unstarted or claimed-but-unwritten snapshot, lands it through `aflush()`, and returns only after the task registration is retired. Off the loop (CLI, tests, worker threads) every mutation still writes inline. Losing a pending flush on a crash leaves a well-formed older map, never a truncated file. **Auto-prune:** `SessionMap.get()` auto-removes entries whose `.json` file no longer exists, or whose `.jsonl` holds no turn (the entry drops from memory immediately; the file write rides the deferred flush). `SessionMap.prune()` bulk-removes all stale entries at startup. The resumability rule itself — kiro-cli's `.json` present and `.jsonl` at least `_RESUMABLE_JSONL_MIN_BYTES`, any other backend left to `session/load` — has ONE definition: `_RESUMABLE_JSONL_MIN_BYTES` read through `_jsonl_holds_a_turn`, which `get()` prunes on directly and `session_files_resumable(sid, provider)` wraps together with the `.json` and provider checks for a reader outside the module (the sub-agent orphan notice's resume hint), so the two cannot drift. Both stale paths (`get()` and `prune()`) ask `_keeps_entry`: an entry carrying a durable setting, a generation floor, a channel binding (`_survives_prune`), or a `temporary` / `incognito` privacy flag loses only its dead `sid` and keeps the rest -- with no transcript read, because both run under the map lock on the event loop. A privacy-flagged row is removed by NO path, whatever its transcript header says: the flag is the record the channel's inbound gate hydrates from (`privacy_mode.hydrate` restores the trackers from the map alone, never from the header), so a row removed for any reason leaves that gate reading the thread as persistent after the next restart -- its turns persisted, agent memory writes admitted. The header is still ensured: `start_pool` runs `SessionMap.stamp_privacy_headers()` right after `prune()` -- awaited in place on the blocking path, inside the task the non-blocking path already schedules on the other (the live-config appliers and the dashboard's background-session restart return without waiting on the sweep, whose cost scales with the retained rows) -- which lists every flagged row under the lock with no filesystem call (`privacy_flagged_entries` -- channel-bound or not, live `sid`, cleared `sid` or none, because the header is the record for the thread, not for the provider session) and, on a worker thread with no lock held, probes each row's transcript header and copies the mode into it where it is missing or weaker (`_header_records_privacy_mode`, tighten-only, `require_existing`: never creating a transcript). It returns a count and removes nothing. A flag TIGHTENED while the probe ran off the loop leaves the header stamped with the mode the worker saw -- never looser than before -- and the next pass reads the current flags and re-stamps the tightened mode. The retained rows are bounded at `PRIVACY_ROW_CAP` (the trackers' own `PRIVACY_LRU_MAX`, 10,000) and the bound is held by refusing, never by evicting: a NEW privacy flag past it is refused fail-closed by `SessionMap.set_flag` (`PrivacyRowRefused`; the modifier then tells the user the message was not processed and does not run it), a key over `PRIVACY_ROW_KEY_MAX` (200) is refused at the same gate, tightening a row already retained is never refused, and no retained row is ever evicted -- it is the record the channel gate hydrates from. Retiring rows once the header carries the mode needs the channel gate to read the header, a separate change. Immortality of durable settings stays opt-in (`_DURABLE_FLAGS`). **Mapped-session enumeration:** `SessionMap.mapped_sids_by_key()` returns session key → kiro-cli session ID for every entry that has one. Disk accounting ([session-storage](session-storage.md)) needs both halves of that relation: the IDs to exclude from reclaiming (a mapped session is resumable), and the key each ID belongs to so a session's transcript can be paired with its replay log. Returning the mapping rather than only the ID set is what lets a caller reclaim a session whole instead of leaving one half behind. **Dashboard history key round-trip:** Session keys use `:` (e.g. `dashboard:chat-1-xxx`) but JSONL filenames use `_safe_key()` which replaces `:` with `_`. When a session is resumed from history, the slot name comes from the filename stem (`dashboard_chat-1-xxx`), producing session key `dashboard:dashboard_chat-1-xxx`. `SessionMap.get()` handles this by falling back to the canonical form (`dashboard:chat-1-xxx`) when the direct lookup fails. **Slot-key filename normalization:** `get_or_create_slot()` folds every caller-provided slot name to the `_safe_key()` filename charset (`[A-Za-z0-9_\-.]`, via `_normalize_slot_key()` — `dashboard:`/`dashboard_` transport-prefix strip mirroring `_history_key_for()`, then ASCII fold, then filename fold), so a slot key always equals its persisted filename stem. A folded key longer than `_SLOT_KEY_MAX_CHARS` (255 minus `.jsonl`, the filename limit past which `open()` fails `ENAMETOOLONG`, so no transcript can exist under it) is shortened to its first 180 characters, `-` and the first 12 hex digits of its sha256: stable, distinct per name and unchanged when folded again. A key at or under the limit keeps its spelling, so an existing transcript is still found. Without this, display-style slot names (e.g. `Artifact: My Doc` from the artifact iterate flow) diverged from their sanitized filename: after a gateway restart, `restore_open_slots()` rehydrated the raw key from `open_slots.json` while `restore_recent_sessions()` derived a second slot from the filename stem, producing duplicate sidebar sessions backed by one transcript. `restore_open_slots()` and `_rehydrate_slot_from_history()` apply the same fold on read so pre-fix snapshots carrying both key forms self-heal (the second form hits the dedup guard); the open-tab read screens and folds each key through `_sanitize_open_slot_key` (`dashboard/slot_persistence/restore_inputs.py`). When normalization changes the name, the original pretty form is preserved as the slot's initial title (redaction-scrubbed, non-pinned so auto-title can still override). **Permanent history deletion keeps ownership exact.** `DELETE /api/sessions/{key}` unlinks the selected transcript first. History aliases may locate a slot candidate, but they do not prove ownership. Before the unlink awaits, the route captures the slot object, its transcript key, its SessionManager key, and the key's monotonic ownership generation. Legacy Slack aliases can name either a canonical or pre-migration bare file, so the off-loop delete worker resolves the selected history key and captured slot transcript to the filename each one actually uses while holding that transcript's cross-process lock set. Slack deletion, restore, and ordinary transcript writers all use `ConversationLog.locked_stems` to take canonical `slack_` and legacy `` locks in sorted order; writers resolve their physical target only after that set is held, so none can publish through an alias the others did not serialize. A path mismatch rejects the candidate. Deferred cleanup compares object identity, task identity, current transcript and SessionManager routing, and the current manager generation with the immutable claim in the same yield-free span as the pop. Cron, workflow, and channel adoption can relink an existing slot object; a rerouted object is preserved even though its identity is unchanged. A new turn on the same route changes its task or generation and is preserved too. A captured absence never claims a later successor. Every `get_or_create` reserves its logical key under the registry lock before resume lookup or provider startup. Reservation publication/removal and every session registration/removal — including provider-reload and shutdown mass clears — advance the generation shared by canonical and legacy Slack aliases. Successful claims remove their token synchronously before returning, in the same yield-free span that owns the acquired lease. Failure and cancellation drain token removal under the registry lock. `destroy_if` requires the captured generation to remain current, requires no reservation and an idle session, then evaluates current live-slot ownership under the same manager lock as the provider pop. The session-map entry is deleted before the awaited end metric. History deletion uses the explicit override-preserving mode; ordinary destroy continues to clear the old conversation's threshold. Chat pins, work ledgers, and per-session autocompact overrides are preserved. They are independent stores that can be claimed by a transcript created or restored in another process after any owner scan or in-process epoch check. Making their deletion atomic would require every cross-process transcript writer and restore path to share one mutation protocol with in-memory dashboard state. The request path chooses the smaller fail-safe rule instead: stale sidecars are reversible, while deleting a successor's state is not. ## Slack Thread Linking Sessions can be linked to Slack threads via `SessionMap` fields `slack_thread_ts` and `slack_channel_id`. This enables bidirectional sync between dashboard chat and Slack. Slack is the legacy special case: other channels link through the generic ChannelLink mirror map (see [messaging.md](messaging.md)). The `slack_*` fields are retained for backward compatibility. **API:** - `SessionManager.set_slack_link(key, thread_ts, channel_id)` — persists to session map - `SessionManager.get_slack_link(key) -> (thread_ts | None, channel_id | None)` - `SessionManager.get_session_for_thread(thread_ts) -> key | None` — reverse lookup, keyed by the **bare** Slack `thread_ts`; returns the linked session key (canonical `slack:` for self-linked Slack threads, `dashboard:chat-N` for dashboard-linked threads) - `SessionManager.set_channel(key, channel_id)` — backward-compat alias **Slack handler:** calls `set_slack_link(session_key, reply_ts, channel)` (where `reply_ts` is the bare Slack thread_ts and `session_key` is the canonical `slack:` form) outside the `if is_new` guard so every message refreshes the link. ## Slack Session-Key Alias Fold Slack thread sessions have two historical key forms: the legacy bare `thread_ts` (`"1783733803.877979"`) and the canonical namespaced form (`"slack:1783733803.877979"`, `messaging/link.py`). The Slack handler derives the canonical form at message entry (`canonical_key(thread_ts or msg_ts)`), but legacy callers and persisted state may still present bare keys. `SessionManager._fold_key(key)` resolves the two alias forms onto whichever form is live in the in-memory registry (exact match → canonical alias → legacy bare alias; unknown keys pass through unchanged, so non-Slack namespaces are never rewritten). Every public key-taking method (`get_or_create`, `has_session`, `get_provider`, `get_pid`, `release`, `stop_turn`, `enqueue`/`dequeue`/queue helpers, `reset`, `remove`, `destroy`, approval-policy accessors, `record_success`/`record_failure`, `check_context_usage`, `cancel_current`, `is_provider_alive`) folds at entry. Without the fold, the thread-index lookup (which returns canonical keys) and a live session registered under the bare key disagree, so the second in-thread message misses the live session, the disk resume is rejected by kiro-cli ("Session is active in another process"), and a brand-new context-free session silently splits the thread. `ConversationLog._path()` applies the same back-compat: a canonical key whose file doesn't exist yet falls back to the legacy bare-`thread_ts` filename when that exists, so a thread active across the migration keeps one log file. **Dashboard chat:** mirrors user messages to linked Slack threads via `slack_client.post_message()`. Linking a dashboard session to Slack (`dashboard/chat_slack.py`) opens a DM thread, links the session, and backfills the last five turns as context (`chat_backfill.DEFAULT_RECENT_TURNS`). **Dashboard state:** `ChatSlot.summary()` includes `slack_linked: bool` so the frontend can show a link indicator. `_ChatSlot.task` publishes ownership through a property that increments `_turn_generation` for every new non-null task. The counter is process-local and monotonic for the slot lifetime; unlike `task`, normal turn teardown does not clear it, so code spanning an await can detect a turn that started and finished inside that interval. **Slash commands** (`slack/events.py`): - `/kirocrew sessions [all|ended]` — lists the last `slack.sessions_limit` recent sessions as task cards, each with a Resume button (`mc_session_resume_{key}`) **Block Kit builders** (`slack/blocks.py`): reusable Block Kit dict builders for slash command UIs. Action IDs follow `mc__[_]`. ## DM Channel Session Keys & Mid-Turn Handling DM channels have no thread concept, so every DM dispatcher (Discord, Feishu, iMessage, Teams, Telegram, Webex, WeCom, Weixin, WhatsApp) goes through `messaging/link.py`, which derives the session key with `build_dm_session_key(channel, agent, user, *, gen, dm_scope)`: - **Shape** (channel-first): `{channel}:{agent}:{chatType}:{user}` plus an optional `:gen{N}` suffix. The part before the suffix is a durable **bucket** (history and channel links hang off it); the **generation** rotates to start a fresh transcript within the bucket. `chatType` is a free string segment each channel owns (§9 pins only the grammar, not the vocabulary): `direct` is the baseline 1:1 DM; `forum` keys a group forum Topic to `(chat_id, thread_id)`; `direct_topic` keys a **private-chat** forum Topic to `(chat_id, thread_id)` (Telegram — still a 1:1 DM, but per-topic isolated); Discord also uses `group` for a guild thread. - **`dm_scope`** (`MessagingConfig.dm_scope`): `per-channel-peer` (default) — one bucket per `(channel, user)`; `unified` — all DMs collapse into a single `unified:{agent}` bucket for cross-surface continuity. `agent` is part of the bucket by design, so switching the configured agent starts a fresh session rather than replaying another agent's context. - **Generation reset** rotates on `/new`, an idle window (`MessagingConfig.idle_reset_minutes`), or a daily boundary (`daily_reset_hour`), decided by `should_rotate_generation()`. - **Explicit `/new` is durable on every DM channel.** Discord, Telegram, Teams, Webex, Feishu, iMessage, WhatsApp, Weixin and WeCom persist the new generation as a monotonic floor on the stable `SessionMap` bucket and await its flush before replying. A failed floor write leaves the in-memory bump intact but adds a restart-safety warning. A zero-turn generation creates no conversation-log row: it holds no work to recover, and repeated `/new` calls therefore update one floor integer instead of crowding the newest-first picker with empty placeholders. The first normal turn creates the real history row. Automatic idle/daily rotation still materializes only when its first real turn runs. - **Restart-safe generation seeding.** The generation counter is in-memory (per `ConversationState`), so it resets on gateway restart. To stop `/new` from bumping a reset counter (0→1) straight onto a still-persisted generation and resurrecting that old conversation, the counter is seeded on first access to a bucket from the highest mapped generation or explicit-new floor via `SessionMap.max_generation(bucket)` (shared helper `messaging.link.seed_generation`, used by every DM dispatcher). A normal post-restart message then resumes the latest generation (continuity); `/new` always advances past every persisted generation, minting a genuinely fresh sid. Legacy bare-thread Slack keys are unaffected — they keep the `canonical_key`/`legacy_key` shim. The DM channels are recent, so the key shape carries no prior persisted history to migrate. Slack does not use this key shape even for its own DMs. `slack.dm_single_session` gives a 1:1 DM one session by keying it `slack:` (`slack.transport_dispatch.flat_dm_session_key`) rather than by minting a four-segment bucket: a two-segment Slack key is what the existing thread keys already are, so the fold shim, the thread index and every caller that reverse-derives from a Slack key keep working unchanged. It carries no generation suffix, so the idle/daily rotation above does not apply — a flat DM relies on ordinary context compaction instead. ### Mid-turn messages (steer / queue) `SessionManager.is_busy(key)` reports whether a turn holds the session semaphore. When a DM arrives mid-turn, the dispatcher acts on `MessagingConfig.queue_mode`: - `steer` (default): fold the message into the running turn via the provider's steer channel. - `queue`: enqueue it — checked atomically against the semaphore, so a turn that finishes in the window runs the message instead of stranding it — and drain it after the turn, iteratively and capped (not recursively). A DM bound to a **resumed dashboard session** is the exception: `is_busy` there usually means the DASHBOARD turn loop holds the semaphore, and the channel's queue is drained only at the tail of a channel-driven turn with resume routing off, so a message enqueued there would later run in the channel's native session. Discord hands such a message to the dashboard slot's own machinery instead (`dashboard/channel_handoff.py`): the slot's steer path (`steer_into_running_turn`, recording the same audience fence a peer steer records) or the slot's queue (`queue_for_next_turn`, drained by the dashboard turn loop), and confirms in the DM. The refusal stays when the slot cannot take the message — no open slot, a closing slot, a relay archive, a slot that is not itself running the turn (a channel-driven turn on the resumed key), or attachments; an incognito or temporary session is taken like any other, since those modes keep their transcript and queue. See [messaging](messaging.md#a-busy-resumed-dashboard-session-takes-the-slots-own-machinery-discord). WeCom always steers regardless of `queue_mode`: its replies are bound to the inbound request, so a queued-then-drained reply can't be delivered later (see [messaging](messaging.md)). ### Queued prompt durability A prompt admitted while the slot is busy is answered `{"ok": true, "queued": true}` and held in `slot._queue`. Its transcript row is written by the DRAIN, not by the enqueue, so the queue is the only record until it runs — and a gateway restart in that window (an auto-update, a watchdog exit) would drop the prompt with no row, no error card, and an empty queue on reload. The slot save therefore persists it. `slot.durable_queue_entries()` (`slot_queue_repository.durable_queue_entries`) selects the entries and the metadata line carries them as `queued_prompts`, a `SLOT_OWNED_META_KEYS` field so absence clears it. - **The accept STARTS the write, it does not wait for the interval.** `queue_for_next_turn` hands `flush_slot_now` to the executor (`start_queue_persist`) so the residual loss window is one save's duration rather than one flush interval. BOTH accept sites use it: the busy-slot path and `chat_handlers`' sub-agent hold branch, which holds an IDLE slot where no drain is coming and the wait for the last sub-agent is unbounded. Each accepts onto the same queue under the same ceilings, so each starts the write. Started, not awaited: the acknowledgment keeps its existing meaning — accepted in memory, durable on a flush — because making durability a precondition would refuse a queued send on a slow or failing disk, taking the user's words away at the one moment they cannot be re-read from the transcript. A failed background write is logged and stays owed to the periodic flush; with no running loop (a synchronous caller) the flush owns it as before. **One writer per slot.** Two immediate writers would snapshot independently and the transcript's file lock orders their commits, not their reads, so the older snapshot could land last and put back a value missing an acknowledged prompt. A send arriving mid-write records the debt (`_queue_persist_owed`) and the finishing writer runs one follow-up pass when the queue still differs from disk. That flag gates only the writers it starts, so the save closes the rest: inside the history lock it compares the slot's committed-queue witness (`_queue_persisted_sig`) against the value held when it read the queue, and refuses the pass when another writer committed in between. Only a committed value refuses — a queue that merely moved is an ordinary consistent past state — and a refused pass leaves the queue owed rather than dropped, so losing the race costs one flush interval of lag instead of an acknowledged prompt. A rows-only write over another holder's line is exempt, since it defers the key instead of deciding it. Both refusals this adds mark the slot dirty on the way out, because `flush_slot_now` clears `_dirty` on any return that did not raise and compares `_dirty_gen` to spot a concurrent mark: without it a refused pass would drop window rows it never wrote, and the queue signal cannot cover them once the overtaking writer has satisfied it. The empty-window merge of a message-less slot takes the stale-queue and the replaced-slot refusals only when the caller passes `refuse_stale_empty_merge`, which the periodic flush alone does because it re-runs a refused pass on its next tick. A route save (tag, folder, pin, transfer) and a close commit unconditionally, since their callers roll back an acknowledged edit or ignore a refusal; a route save holding a queue snapshot older than a committed queue still commits its edit and leaves the on-disk `queued_prompts` as written (`queue_deferred_under_lock`), so the queue stays owed. On a tab with no metadata line the merge writes nothing and credits `_queue_persisted_sig`, so queue drift alone does not re-run the save on every tick. - **Run now dispatches an idle queued card; it does not cancel child work.** `POST /api/chat/slots/{slot}/interrupt` keeps its stop-and-preserve behavior while the parent turn is running. When the parent is idle, the endpoint **requires** an explicit `queue_id` naming the selected card: it revalidates that exact card and starts it directly. A missing, empty, or non-string `queue_id` on the idle path is rejected with `400 invalid_queue_id` — the bypass exists only to run a card the user chose, so it never falls back to the queue front or dispatches unselected work. The running path leaves `queue_id` optional: a running-parent interrupt is a stop-and-preserve, and the `queue_id` there only promotes the matching card to the front, so a value that matches no card is a no-op rather than a refusal. This explicit action bypasses only the background-subagent user-message hold; an active stage, a stop in progress, or a relay archive still refuses it. Attached subagents keep running, and their later completion injections use the ordinary busy-slot queue. If admission revalidation removed the selected card, the endpoint returns `409 queue_item_unavailable` so the client releases its pending-action latch instead of treating a no-op as accepted. Interrupt requests that produce a command outcome are audited to the SEL as a `dashboard_interrupt` command: the idle dispatch logs `outcome="started"`, a running press logs the `stop_turn` outcome (`soft` / `hard` / `idle` / `compacting`), and a superseded or already-in-progress press logs `outcome="noop"`. Requests rejected before a command outcome, including validation and idle-dispatch race refusals, are recorded by the generic mutating-API audit instead. - **Only a plain user prompt is durable.** An entry carrying a `kind` is an injection whose producer is gone (a cron notification names an event, and a restart is not that event happening again); an entry carrying a `payload` is a synthetic recovery continuation; an entry carrying `_on_consumed` / `_on_irreversibly_consumed` acknowledges an automatic payload through a callback that does not survive the process. `meta` rides along verbatim, because it holds the admission-time containment snapshot the drain re-validates against and an entry without one fails closed. - **Provenance does NOT survive the restart, and that is a security property.** `_directive_user_origin` / `_directive_channel_origin` record that an entry's words came from an authenticated human, and the drain reduces the consumed entries' flags into `producer_is_user_facing`, which admits user-surface and self-arming directives and exempts them from the LINKED containment constraint. The metadata line is an ordinary readable-writable file in the crew home, not a write-protected one, so a flag read back from it is indistinguishable from one a prompt-injected agent's shell wrote — authority granted to whoever can write the file. Neither half of the round trip moves it: the keys are absent from `_DURABLE_QUEUE_KEYS` so the writer never emits them, and `sanitize_restored_queue` drops a hand-added one, so a restored entry is non-directive by construction. - **Correctness rests on the window and the queue being ONE observation.** The drain removes the entry and appends its row in the same event-loop step, and the save runs in the flush executor thread, so the save takes the pair under one consistency generation: read the queue, snapshot the window, read the queue again, retry while the two disagree, and REFUSE the save (nothing written, the entry still owed) when no pair is proven inside the budget. Read separately, the two halves could commit a file showing neither the entry nor its row. A crash between the drain and the save loses the row as well, so a replayed entry is a prompt the transcript never recorded — never a second copy of one it did. The pairing and the in-lock committed-witness refusal are the save's guards in `dashboard/slot_persistence/write_guards.py` (`paired_window_snapshot`, `_queue_snapshot_is_stale`); the value rides the line that `metadata_line.build_full_line` folds. - **Restored entries are handed back as queue CARDS, not dispatched.** Nothing drains an idle slot on boot, so the user sends, edits or deletes them. This is the same rule `sendTurn.ts` follows for an indeterminate send: a prompt whose turn may have run un-persisted must never be auto-resent. - **Durability does not depend on the mutation site.** `_queue_persisted_sig` records what the last committed save wrote and `slot.queue_persist_pending` compares it against the live queue, so an in-place rewrite — a reorder, a force-stop clear — is picked up by the periodic flush without each site marking the slot dirty. The flush and the resumed-slot no-op guard both read it beside `_dirty`. - **A rows-only handover save defers the key** (it is in `ROWS_ONLY_DEFERRED_META_KEYS`): the line describes the live holder, whose queue this write does not own. The popped slot keeps owing its own entries, which is the conservative side. `_persist_handover_tail` therefore treats an owed queue as work — a queued prompt changes neither the window length nor `_dirty`, so its "nothing owed" test would otherwise answer True over one — and judges each owed entry's durable future by who writes the line next: an entry with none is reported in every register that can still hold it — the warning log, the `prompts_lost` field of its returned result, and a dashboard notification that names the slot and the count (never the prompt text). Nothing in the process visits that slot again, the same position the held `/note` lines are in. - Bounds: `MAX_DURABLE_QUEUE_ENTRIES` entries and `MAX_DURABLE_QUEUE_BYTES` of serialized value, admitted front-first because the front runs first. An over-budget prompt is DROPPED, never truncated — a shortened prompt replayed as the user's own words is worse than one reported as not carried. The SAME two ceilings gate the restore, costed against the SAME key projection, because the writer's bounds bound only what this gateway wrote and the line can be edited outside it, while whatever the restore admits is retained live and re-serialized by every later save. The projection matters: the restore adds `kind: ""` to keep a restored entry out of the system-injection paths, and billing itself for that key would make the reader's budget the smaller of the two, so a queue written just under the ceiling would drop its tail on the way back in — losing a prompt that WAS durably written. `MAX_DURABLE_QUEUE_SCAN` bounds how much of the raw value is INSPECTED, which the retention cap does not: the apply phase reading it is loop-affine, so a hand-edited line must not buy startup work proportional to its own length. Entries past it are counted as not restored, never quietly ignored. - The send is **not refused** when the bounds cannot carry it: taking the user's words away at the one moment they cannot be re-read from the transcript is worse than a best-effort durable copy. `durable_queue_view` returns the persisted entries and the candidate count from ONE read of the queue, and the save logs their difference once, naming the slot, so the omission is an operational fact rather than a silent one. The pairing matters: counted from two reads, an ordinary send landing during a save is reported as a prompt the bounds refused. The ACCEPT reports it too, and earlier: `warn_if_not_durable` logs at WARNING when the entry just queued is one the write will not keep, naming the slot, the entry, the candidate count, the carried count and which ceiling refused it. Both the verdict and the reason are read off `durable_queue_entries`' own output — an entry absent from a full set was refused by the count cap, absent from a short one by the byte budget — so nothing re-implements the interacting ceilings. This is a LOG, not a receipt field: a caller-visible `durable` boolean on the acknowledgments has no reader, so it is not shipped, and the on-screen queue-card marker belongs with its consumer (issue #11695). ## Cross-Surface Reply Mirror The same conversation can appear on a channel and in the dashboard. Two models relate the surfaces: - **Slack — one session, two surfaces (fold-in).** A linked Slack thread folds into the dashboard session: the handler swaps the session key to the linked dashboard session via `get_session_for_thread`, so there is a single backing sid and Slack is a projection of it (see *Slack Thread Linking*). - **Discord / Telegram / Webex / Teams / WeCom / Weixin — two sessions, bridged by a mirror.** The channel message runs under its own channel session (`{channel}:…:genN` → its own sid); the dashboard surfaces it as a separate slot with its own sid. One logical conversation therefore has two backing sids, bridged by the mirror. `messaging.link.legacy_dashboard_mirror_key(channel_session_key)` computes the dashboard-side key: `"dashboard:" + history._safe_key(channel_session_key)`. It MUST use the same `_safe_key` sanitizer as the slot-naming path (every non-word char → `_`, not only `:`); a narrower sanitizer silently mismatches for keys containing spaces/unicode, so the mirror never fires despite `/link` succeeding. **Directions.** Inbound (channel → dashboard display) is independent of the mirror link and always on — the channel turn writes the shared `conv_log`, which the dashboard rehydrates as a slot. Outbound (dashboard → channel echo) fires only when a `mirror` `ChannelLink` exists on the dashboard-side key: ``` Messaging channel Dashboard tab ┌────────────────────┐ inbound: ALWAYS ON ┌────────────────────┐ │ channel session │ ═════════════════════▶ │ dashboard slot │ │ …:genN (sid A) │ │ dashboard:…_genN │ │ │ ◀── outbound: only ── │ (sid B) │ └────────────────────┘ when /link is ON └────────────────────┘ ``` **API:** - `SessionManager.set_mirror_link(key, link)` / `clear_mirror_link(key)` / `get_mirror_link(key)` — persist/read the outbound `ChannelLink` (Slack routes to `set_slack_link` so its reverse index stays intact). The read synthesizes a Slack mirror from the legacy `slack_thread_ts` / `slack_channel_id` fields only when a thread is named: a threadless Slack row — the bucket `set_channel` stamps on a channel session's first turn, which `clear_mirror_link` leaves behind — is bookkeeping nobody can deliver through and reads as `None`, so no reader needs its own copy of that rule. `clear_mirror_link` takes BOTH spellings a channel session's binding can live on in one save — the canonical row and the pre-unification `dashboard:` row it superseded — because the read falls back to the older row the moment the canonical one is gone: popping the winner alone left the session reading as mirrored to its previous target, and the dashboard redrew the row its Unlink had just reported removed. The link may also carry the **peer it was admitted for** (`ChannelLink.principal`), stored inside the `mirror` row and read back with it: the dashboard's mirror-link handler records the `user:` it resolved and admitted, a Discord `!sessions` pick records the pressing owner, and every other writer (an origin bind, a room or thread target) records none. Because `session_map.json` is writable by in-sandbox code, such a row carries an `admission` — HMAC-SHA256 over the canonical session key and the whole location under a key derived from the sandbox-masked `token_signing.key` (`kiro_crew.mirror_admission`) — minted ONLY by those two creation paths before they hand the link over. `set_mirror_link` never mints or repairs one: it stores the caller's bytes verbatim, so a rollback that re-sets a row it read back keeps a verifying admission byte-for-byte and can never sign a planted one (every rollback first passes the row through `mirror_admission.restorable_link`, which strips a peer whose admission does not verify), and a row assembled anywhere else never verifies. Neither field is part of the binding's identity — the nonce rule, `find_mirror_sessions` and the location sweep compare links by location alone; a rollback's ownership guard alone compares the whole row (`ChannelLink.same_row`) — and both are replaced with the row, so a rewrite that names no peer stores none. Their reader is the per-send recipient check in [messaging](messaging.md) (§ Proactive sends), for a dashboard-born session whose key names nobody: it hands the roster the peer only when the admission verifies for that session and location, and a rotated signing key refuses every such mirror until it is re-linked. - `SessionManager.clear_mirror_link_if(key, channel_type, token)` / `clear_slack_link_if(key, channel_type, token)` — compare-and-clear as ONE step under the map's own lock: the binding held is recomputed into its row token (`messaging.link.binding_token`, with the binding's persisted nonce) and cleared only if channel and token both match; False is a mismatch — the binding changed, or there is none — and nothing is touched. The only way an unlink that NAMES a binding clears it. A route that reads the binding, compares it and then clears it performs two steps, and a rebind landing between them (another thread's `!sessions` pick, a rival claim, the dispatcher re-asserting an origin mirror) is cleared by a stale unlink that matched the binding before it — so the routes hold no compare of their own (`test_channel_connect_row.py::TestCompareAndClearIsOneStep` enumerates the unlink routes and asserts it). The mirror twin clears exactly as `clear_mirror_link` does (both spellings, one save, the unbind audit); the Slack twin clears both key spellings of a dashboard session. - `SessionManager.clear_mirror_links_at(link)` — value-keyed sweep: clears EVERY session whose mirror targets that exact non-Slack location and returns the cleared keys. The write counterpart of `find_mirror_sessions`, and the only clear that reaches a binding stranded under a key spelling the conversation no longer derives (a rotated DM generation, or a pre-unification `dashboard:` row of a DIFFERENT session — the same session's own legacy row is within `clear_mirror_link`'s reach). - `POST /api/chat/slots/{name}/mirror-link` | `mirror-unlink` — dashboard-side endpoints (auth posture matches `slack-link`: under the `/api/chat` `mixed_internal_paths` prefix, never the strict `internal_paths` set). New links use `{channel_type, target_id}` and resolve the opaque configured target server-side; the legacy `{conversation_id, thread_id?}` body remains accepted for compatibility. A successful new link posts an anchor plus the last five redacted messages before persisting the mirror. `mirror-unlink` is what the session menu's **Unlink from X** item calls for EVERY row — a body naming a `slack` binding is the slot's Slack thread, and the handler hands it to `slack-unlink`'s (which owns that teardown: both key spellings, the slot's own fields, the thread's reverse index, the courtesy note). Which store a binding lives in is the server's fact, kept beside the code that enforces it (`mirror-link` refuses Slack on channel type, so no slack-typed mirror is creatable, and `SessionMap.get_mirror_link` already reads the thread as the session's mirror); the menu carries no channel-to-endpoint switch of its own — the same client-side inference, made for the `driven` flag, renders a paused Slack row as a one-way link. It is the one user-facing action that SEVERS an explicit binding, as distinct from the Disconnect row, which pauses it. #3006 removed the sever actions (the chip's Release button, the menu's Stop-mirroring items) when it made Disconnect mean "output stops, the binding stays"; the Unlink item reinstates one, on the evidence of #14068, because a paused binding is still a binding: it still routes that conversation's messages into the session and still counts as a mirror for session control, so a session disconnected from the menu stays refused ("sessions mirrored to a channel cannot control other sessions") with no exit short of an in-channel `/unlink`. The item is offered on every channel the session is explicitly bound to and never on a channel the session was BORN in: a channel-born session carries two rows for its own conversation — the `origin` row and the self-mirror `bind_origin_mirror` writes on every inbound turn — and popping that mirror would leave dashboard-taken turns and the auto-compact notice reaching nobody until the next inbound message rebound it, so the menu judges the group by its `origin` row and offers Disconnect alone there. On success the dashboard drops that channel's explicit link rows at once (`dropSlotLinks`) and the endpoint's own `push_slots_update` confirms it. **The request names the binding it severs.** Both `mirror-unlink` and `slack-unlink` accept an optional body `{channel_type, binding}` spelled as the slots projection spells the row: the channel and the row's opaque `binding` token, a digest of the WHOLE binding — channel type, full conversation id, thread id and the binding's own persisted nonce — minted by the projection (`messaging.link.binding_token`) and recomputed by the map from the binding it holds inside `clear_mirror_link_if` / `clear_slack_link_if`, which clear only on equality; the body reader `_expected_binding` in `dashboard/state.py` is the routes' one piece of the guard, and the routes hold no compare of their own. The token, not the redacted `target`, is the identity, because the display tail drops the thread and the id's head: a Slack thread and its same-channel replacement (a re-link after an unlink, or a Slack-side resume, both land in the same owner DM on a fresh thread) read alike on `target` and differ on `binding`. The nonce is what separates a binding from its byte-identical recreation: `SessionMap` mints one (`mirror_nonce` beside `mirror`, `slack_link_nonce` beside the Slack fields) whenever a binding is created or its target changes, keeps it across an identical rewrite (the inbound paths re-write the same coordinates on every turn), and drops it with the binding — so unlink → reconnect the SAME target yields a new token, and a delayed unlink from a tab still holding the old row is refused instead of deleting the binding another tab just made. A binding written before nonces existed has none and its token digests the coordinates alone; the binding that replaces it carries a nonce, which is all the guard needs. The menu always sends it, because the row it renders can be stale — a tab that missed a slots push while its socket reconnected still shows the old row after another tab rebound the slot — and a key-only clear would delete the binding the clicker never saw. A mismatch, or no binding at all, is 409 `mirror_changed` and clears nothing; the menu reports it under the row as **"The X connection changed while this menu was open. Nothing was unlinked; the row was refreshed."** — worded after what the refusal knows (the connection the row was drawn from was replaced, nothing was cleared) and never as "no longer linked", because a nonce-only mismatch (a Slack thread replaced in the same channel, a rebind after an unlink from a second tab) means the channel IS linked, to a new target, and the refetch redraws the row as connected beside the notice — and refetches the slots. While that notice shows the channel's Unlink item is not rendered at all and the toggle row's consequence line and direction tag are withheld — the refetch may redraw the very same row (a binding re-linked on the same channel), and an Unlink (live or dimmed — a dimmed one still read as "odd"), or a line or tag saying the connection stands, directly under "nothing was unlinked" contradicted it; they follow the notice and return with its dismissal (or the row's next click). The agent hand-off item stays, for the stale refusal as for every other failure: inside Radix menu content it is the notice's only keyboard-reachable escalation and renders in the notice's own branch, `aria-describedby` the notice's id (`errors-use-error-notice`'s third sanctioned state — a notice with neither `askAgent` nor that sibling is a dead end), and a notice that says the connection changed is a question the agent can answer — which the item now says under its label, in the menu's own sub-line grammar, **"Ask what this channel is linked to now."**: a bare sparkle item in a menu whose every other action self-describes was the one control a reader could not identify ("no idea what it does or why it's in this menu"). Only the stale refusal carries that line; an ordinary failure keeps the bare item, as the hand-off reads everywhere else in the dashboard. A body naming the channel without a token is refused the same way (the caller tried to name a row and failed — never the unconditional clear); a body that is present but not valid JSON, or not a JSON object, is 400 `invalid_body` and clears nothing (reading it as "no body" would hand a caller that garbled its row the unconditional clear); only an EMPTY body keeps the unconditional clear for callers that hold no row. The Unlink item names its outcome under its label — for a two-way binding "Removes the connection — X stops driving this session. Reconnect anytime from the session menu.", for a one-way mirror "Removes the connection — replies stop going to X. Reconnect anytime from the session menu." — and every connected row's toggle reads **`Pause replies to X`** — the Disconnect action labelled for what it does, the symmetric twin of `Resume replies to X`, because "disconnect" everywhere else in life means sever and a reader read the row right only through its sub-line ("'disconnect' and 'pauses' pull in different directions"); a label change only, #3006's pause semantics stand — and carries **"The connection stays."** — a full sentence, ended like `still_linked`'s, because the unpunctuated form read as "cut off, like a sentence missing its ending" (every sub-line says "the connection", never "the link": "Copy link" a few items up means a web address, and one word for two things in one menu read as a collision), because stacked under Disconnect the two verbs read as near-synonyms and a reader who cannot tell the temporary one from the permanent one clicks neither; a line under Unlink alone cannot separate them (with nothing under Disconnect there is no second term to compare against), and a bare removal verb reads as hard to undo, so the Unlink line also says that reconnecting brings the link back and where that lives: a reader who understood the verb still would not click it without knowing how easy the way back is. The way back is its own sentence: as a third dash-clause ("... until you reconnect — from the session menu.") the place attached to the nearest verb and parsed as "removes the link from the session menu". The place is named as "the session menu", not "this menu", because the same rows also open under the paused header chip and an unlink from there removes the chip and its menu. The Unlink line no longer names the kind of link: "two-way link" made a reader pause on the one control that ends the lock-out ("I'd pause first because I don't fully understand 'two-way link'"), and the tail carries what stops. The direction itself is an explicit label on every BOUND row, rendered from the row's `drives_session` in the menus' right-aligned tag grammar — **"Two-way · X ↔ this session"** for a binding that drives this session, **"One-way · this session → X"** for a one-way mirror; an offer, bound to nothing, carries none — and the paused sub-line (`still_linked`, one string) carries no direction at all: two rows that looked alike carried captions that flipped between "messages there still reach this session" and "don't reach" with nothing marking why, and a reader who met both stopped trusting either ("I cannot tell which is true … I no longer trust the captions"). The Unlink line keeps its two tails (`unlink_outcome` / `_out`), because what a sever STOPS genuinely differs, and the label above it is what makes the two tails read as two kinds of link rather than as one caption that contradicts itself. The pair defines each other: one pauses and keeps the link, the other removes it and names reconnecting (the menu offers the same destination as a fresh `Connect` row right after) as what brings it back. The pause row's line is not limited to rows with an Unlink beneath them: a reader who has learned it on a mirrored session's menu and then meets a bare `Pause replies to X` on a born-in channel cannot tell whether that one keeps the conversation bound, and the line is true there too — the conversation stays bound and a reply there resumes it. Which direction label a row carries, and which Unlink tail, is the row's own `drives_session` — the slots projection's statement, per link row, that messages sent there land in this session — never an inference from the wire's `direction` or the channel name: a Slack thread row is projected `out` (its inbound routing is Slack's own thread index, not the mirror's inbound marker) yet a reply in a linked thread resumes this session while the link stands and Unlink evicts that index, so the projection marks it `drives_session: true`; a `both` mirror is `true` by its inbound marker, a one-way `out` mirror `false`, and the conversation a session was born in `true`. The server owns the routing fact; a client that re-derives it reads a paused Slack row as a one-way link. A row from a cached payload without the field reads as not driving until the next slots push. - `POST /api/chat/slots/{name}/slack-pause` | `mirror-pause` — disconnect (or reconnect) a channel while **retaining** its binding, so inbound still routes to the same session and a later reconnect needs no re-link. Same auth posture as the link/unlink pair. Body `{paused: bool}`; `mirror-pause` also takes `{origin: bool}` naming WHICH non-Slack delivery is meant, because a session can hold two at once and they mute independently. Returns 409 `mirror_not_linked` when the named delivery does not exist. The disconnect itself is never governance-gated (it only ever reduces egress); a denial silences the courtesy note posted into the conversation and keeps the disconnect. That note is skipped entirely for an `origin` disconnect, since the mirror resolver addresses the EXPLICIT mirror — a different conversation. Because the binding survives, the UI names the state rather than letting a paused channel read like one that was never connected: the menu row reads `Resume replies to X` — the verb names the state its sub-line describes; under a line saying the connection still stands, `Connect to X` read as a second link rather than a resume ("the title says 'Connect' but the small text describes the current paused state… I would not be confident which one I was about to do"). Every paused BOUND row reads it, a paused born-in conversation too: that row carries the direction tag, and a bare `Connect` under a tag asserting the connection stands is the same contradiction. Only a never-connected offer, which asserts no connection, keeps `Connect to X`. Every paused bound row carries a consequence line under the verb — **"Replies paused — the connection stays."**, the born-in row included (with only the verb saying it, a reader found the menu itself said "paused" nowhere), the direction on the row's label, not in the line (a consequence, not the word "linked", which collided with the neighbouring "Copy link" item; and led by what is paused, not by "Disconnected", which under a row reading `Connect` contradicted itself) — and a severable row keeps its `Unlink from X` item; a born-in row carries beneath that line the note that explains its missing Unlink, **"Started from X — the connection can be paused, not removed."**, connected or paused, because a reader who meets a mirrored channel's two controls and this channel's one could not tell "why one connected chat gets both choices and this one only gets the pause". **X is the label the server sends**, everywhere the destination is named: a bound row (and its failure lines) reads its link row's `label` as the slots projection spells it, the header chip reads the same row's label, and an offer reads the target's `label` from `channel-targets` — the same channel form with the destination on it ("Discord DM · 1234"), the one thing an offer must add since several destinations on one channel can be offered at once. On the offer row that label is split at its ` · `: the verb line keeps the channel form (`Connect to Discord DM`, the name the bound row and the chip use) and the discriminator is the row's muted sub-line — a human tail as the transport sent it ("#eng", "Direct Message", "yourself"), a tail that is nothing but the id of a `user:` target named for what it is (**"Direct message · 118273645"**) — because a raw id inside a button label read as broken ("I don't know whose number that is") and the reconnect path Unlink promises lands on exactly that row; an unavailable offer keeps the discriminator above its reason. The dashboard keeps no brand table of its own for these names (its `channelBrandLabel` is only the fallback for a wire row that carries no label), so one destination carries one name across the journey instead of three spellings a reader cannot tell apart; the header chip for a two-way binding reads **"Driven from X · replies paused"** (live: "Driven from X"; unlinked: no chip). Both states are ONE affordance — a menu trigger (with a chevron as its visible cue) that opens the session menu's own Linked surfaces rows FOR THE CHANNEL IT NAMES, right under the chip: for the paused chip the `Resume replies to X` row that resumes and the `Unlink from X` row, with "Resume or unlink X." as its `title`; for the live chip the `Pause replies to X` and `Unlink from X` rows, with "Pause or unlink X." as its `title`; no other channel's rows and no offers (a chip that reads "Driven from Discord DM" must not open a menu offering Slack or Telegram). The paused chip is the one that invites repair — a chip that names a problem and answers a click with nothing is a dead click at the moment of need; a hover-only hint did not cure that (delayed, absent on touch, unread by a screen reader) — and the live chip is the same trigger because a button beside a plain span that looked identical gave one chip two behaviours a reader could not tell apart before clicking. It is not a second control: the rows are the one component that connects, pauses and unlinks, so the chip cannot disagree with the menu about what any verb means. Three states, each spelled differently, because the middle one is the one session control still refuses. **Link, pause and unlink publish only after the SessionMap write is durable**: the map's writer is debounced, so `mirror-link`, `mirror-pause`, `mirror-unlink`, `slack-link`, `slack-pause` and `slack-unlink` each `await state.sessions.aflush()` between the map write and the first thing that reports it done (the slots push, the note or backfill in the channel, the response) — a gateway exit before the deferred write would otherwise reload an unlinked binding on restart, lift a pause the user set, or lose a link whose thread already carries the transcript, and make the reported state a lie. (`mirror-link`'s claim runs off the loop, where a batch writes inline; its await is the same visible contract, normally finding nothing owed.) A failed flush surfaces as the route's error, never as `ok`; on `slack-unlink` the slot's own fields and the thread's reverse-index entry are torn down in a `finally` around the flush, so they follow the in-memory map in failure too — a teardown skipped by the raise would leave the row rendering, a reply in the thread resolving to the slot, and a retried Unlink refused with nothing to compare. They follow the map literally: the teardown is conditional on what the map holds once the wait returns. The write is a real thread hop and a second same-slot `slack-link` can run its whole handler inside it, so a link the map holds afterwards is that newer write (the route cleared the old one), whatever its thread — its fields and index stand, the thread gets no "unlinked" note and no struck control, and the answer carries `relinked: true`; an empty map means the old binding's fields and index are the ones to take down. Pinned in `test_channel_connect_row.py::TestPersistBeforePublish`, one test per route, and `test_chat_slack.py::TestSlackUnlink::test_a_failed_flush_still_tears_down_the_slot_and_the_reverse_index`, `::test_a_relink_that_lands_inside_the_flush_window_survives_the_unlink` and `::test_a_plain_unlink_still_tears_down_the_slot_and_the_reverse_index`. - **Neither `mirror-unlink` nor `mirror-pause` detaches a channel-born slot.** Both act on the outbound mirror binding only (clear it, or mute delivery through it); the slot's `linked_session_key` — the channel session its turns run on — is untouched, inbound keeps routing to it, and nothing about what session control or the work ledger decide for that slot changes. Those gates judge a channel-born slot by `session_control.owner_dm_refusal`, whose one exemption is a 1:1 DM whose only human is the configured owner (see [session-control](session-control.md)); a thread session is refused before and after either endpoint, and an owner DM is admitted before and after — a paused or cleared origin mirror is still the same audience. "Cleared" is the store's word, not an empty row: `clear_mirror_link` pops the `mirror` row and leaves the namespaced bucket the first inbound turn's `set_channel` stamped into the legacy `slack_channel_id` field, and `get_mirror_link` reads that threadless Slack row as no mirror at the source (a row naming no thread is never synthesized into one), so the predicate — and every other reader — sees `None`. The one dashboard action that DOES change the verdict is `slack-link` on a channel-born slot: it binds the thread on the slot's effective key — the channel session — beside the untouched origin mirror, and the predicate reads that thread on its own (`get_slack_link`), because `get_mirror_link` answers the `mirror` row alone when one exists; the owner DM is refused as mirroring to a Slack thread until `slack-unlink` clears it. There is no "detach from channel" action: a channel conversation stays a channel conversation. - **Three persisted pause markers, each keyed differently.** A mute must live and die with the binding the user muted, so the key follows what the flag is about: - `slack_paused` — the Slack thread. Cleared when the binding is REBOUND (different ts or channel), NOT on an identical-coordinate write: the Slack inbound path re-writes the same ts/channel every turn as its thread registry, so clearing on any write let one inbound message silently un-disconnect a thread. - `mirror_paused` — the explicit `mirror` binding. Read/written through `_mirror_key`, following the binding between the canonical row and the legacy `dashboard:` spelling. - `origin_paused` — the conversation the session was BORN in. Read/written on the CANONICAL row, never through `_mirror_key`: that helper migrates rows depending on where a MIRROR lives, which stranded the pause the moment a mirror landed on the canonical row. Each existence check is per flag: a born-in conversation is permanent, while an explicit mirror must actually exist, so a flag with nothing behind it reads as connected rather than reporting a session that delivers nowhere as merely quiet. Enforcement lives at the send sites, not in storage — see [messaging](messaging.md) for the `SilentRenderer` substitution that stops a disconnected non-Slack conversation being written to. - `GET /api/chat/channel-targets` — owner-authenticated union of Slack destinations and every registered transport's configured targets. The dashboard session menu renders this list with per-channel brand icons. Unavailable configured destinations are returned with a reason rather than silently omitted (Teams before first inbound; WeCom proactive send); the menu keeps those rows keyboard-focusable, shows the reason inline, and announces the same reason instead of presenting an unexplained disabled action. - In-channel `/link` / `/unlink` — `/link` writes the link on the current conversation's `legacy_dashboard_mirror_key`; it does not control display, history, or the inbound direction — only the outbound echo. `/unlink` frees the LOCATION via the shared `messaging.link.release_conversation_location` helper (one implementation for every DM dispatcher): after the key-addressed clears it sweeps every binding whose mirror targets this conversation (`clear_mirror_links_at`), including a binding stranded under a rotated DM generation and another dashboard session's outbound mirror into the conversation — the same occupant set the Discord resume conflict check refuses on, so its "Run `!unlink` first" guidance is always followable. The reply reports the count when more than one binding was cleared. **Delivery** (`chat_runner._deliver_cross_surface_reply` / `_deliver_cross_surface_user_message`, via the shared `_resolve_mirror_target` preamble) is best-effort and gated on: Slack skipped (its own inline mirror); a registered transport with `supports_proactive_send` (Feishu is the shipped transport without it, so `/link` is rejected there; WeCom declares it, with per-target availability); and the `channels` governance ceiling via `governance_permits("channels", channel_type)`, so an operator policy restricting outbound messaging is honored on this egress too (fail-closed on any governance error — matching the Slack path). Egress text is redacted through the canonical `redact_via_context` shim so a loaded companion's extra credential/token regexes apply. **Known asymmetry / future work.** Slack already runs the unified one-session model; the other transports run two sessions bridged by the mirror. Folding the dashboard channel tab into the channel session (as Slack does) would remove the second sid and the live render-duplication it can cause, at the cost of a dashboard-turn-loop refactor. ## Session Lifecycle at Startup ``` start_pool() ├── session_map.prune() ├── _stamp_privacy_headers_for_startup() ├── _ensure_background() → BACKGROUND_KEY session (persistent) ├── _fill_warm_pool() → warm pool queue (instant assignment) └── _pool_health_loop() (task) ``` `start_pool(blocking=False)` runs the same sequence in one background task. ## Removing a session from the registry: record the end **Every path that removes an entry from `_sessions` must report that removal to `metrics/sessions.py`** — with `await record_session_ended(key, end_reason=...)` for a session that lived, `await record_sessions_ended(keys, end_reason=...)` for a path that drains many at once, or `await discard_session_start(key)` for a registration being rolled back before it ever became one. All three are coroutines: the breadcrumb unlink is a filesystem syscall and must not run on the event loop, where a slow or network-backed data home would park every gateway task behind one closing session. This is a correctness requirement on lifecycle code, not a telemetry nicety, and it is documented here rather than only in the metrics module because the people who can break it are editing this subsystem. The reason it matters more than a missing sample: a session start writes a breadcrumb file that survives process death, which is what lets a session killed with its gateway be counted at all. A removal that reports nothing leaves that breadcrumb behind, and the next boot reads a surviving breadcrumb as a crash. So an unrecorded removal does not lose a data point — it **fabricates a crash that never happened**, inside the one population the instrument exists to expose. Practical rules when you add or change a removal path: - Report it while you still hold the session registry lock, in the same tick as the `pop` / `del` / `clear`. The call's own pop and histogram record happen before its single suspension point, and holding the lock across that point is what keeps a racing cold start from registering a successor under the same key and having its record consumed by the predecessor's teardown. Reporting AFTER the path's other awaits is the bug this rule exists to prevent. - Drain many keys with ONE `record_sessions_ended` call, never a loop of `record_session_ended` awaits. A per-key await puts a cancellation point between two keys, so every key after it is popped but unrecorded — and on `close_all`, which a cancellation reaches by design, that fabricates a crash per remaining session. - Keep your MANDATORY post-pop cleanup reachable. All three recording coroutines absorb a cancellation at their crumb hop for exactly this reason, so the call itself will not abort you — but the rule that makes that safe is yours to hold: once you have popped the registry you are past the point of no return, so the session-map mutation that finishes the teardown (`destroy`'s `delete`, `discard_conversation`'s and `reset`'s `clear_sid`) must not sit behind a suspension point that can be skipped. Put it in a `finally` that covers every await after the pop, and never add a bare `await` between the pop and it. - Give it its own `end_reason` if it is genuinely a different event. The enum is closed and lives in `metrics/sessions.py`; reusing a label merges two populations, and a metric is not a good reason to grow a lifecycle signature. - A registration cancelled mid-flight is NOT an end. Use `discard_session_start`, which consumes the breadcrumb without emitting a lifetime. `test/metrics/test_session_duration.py::TestEveryRegistryRemovalRecordsAnEnd` enforces this. It is fail-closed and discovers its own scope: an AST walk over every module under `src/kiro_crew` that mentions `_sessions`, recognising the `pop`, `del` and `clear` spellings, so a new removal path anywhere fails the gate the day it lands rather than waiting to be added to a list. A container that merely shares the attribute name can be exempted with a stated reason, and the exemption self-voids if that module ever starts writing breadcrumbs. **`reset` keeps the popped session readable for exactly the life of its teardown.** The pop happens under the registry lock before the awaits that can hang (the end record, the unlink, the child probes, the provider shutdown), so from the pop to the end of the teardown the live map does not name the process the teardown holds. A reader that must still reach that process — the cron reaper, when a run's OWN `finally` reset popped the session and then hung, the ordinary shape of a run that hangs in its teardown — reads `SessionManager.tearing_down(key)`. The facade opens a `_TeardownScope` around every `reset`; `SessionLifecycleService.reset` records the popped session into it in the same lock hold as the pop, and the scope's exit releases the entry however the call ends (return, a deferred shutdown error, a cancellation landing on the hung shutdown). Every teardown in flight under the key is retained, in pop order (`tearing_down(key)` returns the list): a cold start can register a successor while the first teardown hangs, and that successor's own reset can pop it and hang as well -- a reader ending the key must reach BOTH processes, and a table that kept the first popper alone hid the hung successor behind a record that said `reaped`. Each entry is a `TornDown` record: the popped session AND the `ProcessHandle` read off it in the pop's own lock hold (`process_identity.process_handle_of`), never re-read — the teardown's own awaits clear the ACP client's recorded pid after a kill it could not confirm, with the process still standing, so a reader that re-read the retained session found no pid and named no process while it lived; the reader kills on the entry's `handle`. Each scope removes exactly the entry it recorded, by the session's identity, when its own teardown ends. Bounded by construction: an entry lives exactly as long as the reset coroutine that recorded it, which already holds that session, so the table adds a reference per teardown in flight and never a lifetime. A caller that must know exactly which session ITS reset popped opens the scope itself (`SessionManager.teardown_scope(on_pop=...)`, passed to `reset` as `scope`): `on_pop` runs in the same lock hold as the pop, handed that session -- how the cron reaper takes the handle of the process a timed-out reset holds. A `SessionLifecycleService.reset` called without a scope records nothing; the facade is the caller that opens one. `test_session.py:: TestResetRetainsTheTornDownSession` pins the entry's life; the cron module spec records what the reaper does with it. **A key whose run is being ended admits nothing -- and drops nothing: the per-key ending fence.** `SessionManager.ending_key(key)` is a context manager, the per-key sibling of the manager-wide `_closing` check, held by a caller that is ending a run (the cron reaper, `cancel()`, and the sub-agent manager's force reap -- a deadline, a startup stall, a dashboard Stop -- through `process_identity.ending_fence`, the one entry both supervisors use) from before its reset-then-kill passes until the run's terminal record and audit are written: a caller held at the door wakes to a key whose run is RECORDED, never to one that is neither being ended nor recorded (runtime and durable state agreeing; a registration after the passes is a new life under the key, not the run's process, and it follows the record). It raises synchronously on entry (`SessionAllocationService.begin_ending`, before the holder's first await) and lifts however the block ends (`end_ending`). While it is up, a claim or a cold start under the key is HELD at the front door of `get_or_create`: the call takes no reservation, waits outside the registry lock for the fence's lift event (`SessionRegistryState.ending_lifted`, set by `end_ending`; `wait_for_ending_fence`) and then proceeds -- so a sub-agent completion that races the reap of its parent's run is delivered into the session that follows the record, as it was before the fence existed, never into the run being ended and never dropped. The wait is bounded by `ENDING_FENCE_WAIT_SECS` (180 s: an independent caller-refusal bound, not a multiple of the fence's length -- a reset cancelled at its timeout still finishes its cleanup inside the pass, so no duration is derived from the passes -- fixed well inside the 1200 s the completion path allows its whole delivery); a fence still up at that point is a holder stuck past its bounds, and the caller is refused with `SessionEndingError` so the defect surfaces rather than hanging every caller of the key. The front door is not the whole rule: `get_or_create` takes its allocation reservation and then awaits `provider.start()` BEFORE publishing into the map, so a cold start caught inside `start()` when the fence goes up has published nothing the holder's passes could see. `begin_ending` therefore INVALIDATES every reservation in flight under the key at that moment (`SessionRegistryState.invalidated_reservations`, keyed by the reservation token `get_or_create` threads into `_get_or_create_impl`): the body's doors -- the claim of a live session, that claim again once its wait on the busy turn's permit ends (`_reacquire_and_validate`, under the lock, before the session is handed back: the wait can outlast a fence rising, and the turn's ordinary release would otherwise hand the permit to the waiter before the holder's reset pops the session), the cold start before it spawns, the registration after `provider.start()` -- refuse that call (`_refuse_if_ending`; the reacquire door releases the permit it just took), the provider it started is hard-killed by the same `except BaseException` path a closing manager uses, and `get_or_create` then removes the reservation, waits for the lift and allocates again, so the held request lands with a fresh provider; the invalidation lives exactly as long as the reservation (dropped in `_remove_reservation_now`). A reservation taken after the fence lifts is a new life under the key and is not invalidated. A reservation is not yet a process: `SessionRegistryState.spawning_reservations` marks a reservation from the pre-spawn fence check (the spawn door) -- or from a warm-pool claim, which owns a live process from the claim on -- until registration (published, or a won race -- cleared in the same lock hold, with no await between), and `SessionManager._spawn_in_flight(key)` (`spawn_in_flight`, a reason phrase or None) is the read the holder makes after its passes to name a start it did not itself answer -- a cold start inside `provider.start()`, refused and hard-killed when it returns. A start that returns DURING the passes is refused at once and its reservation removed, so it leaves a receipt instead (`SessionRegistryState.refused_spawns`, written by `_remove_reservation_now` while the key's fence is up, cleared by `end_ending`): the hard kill the refusal dispatched runs off the loop with no outcome read back, so the holder names it too ("refused at registration during the ending ... an outcome this record does not confirm") rather than recording `reaped` over a process the cleanup had erased every trace of; a refusal after the lift leaves no receipt, since the record it could inform is already written. A claim waiting on the live session's turn semaphore, or a cold start still ahead of its spawn door, holds a reservation but is NOT named: it started nothing, is woken or held, and lands after the record (naming it recorded a kill failure over a clean reap). A caller held at the front door holds no reservation at all. `open_task_session`, the other publication door, is refused at its entry while the fence is up rather than held: it reserves the key only around its create (so `has_allocation_reservation` and `session_keys()` see the window) but is never refused at registration and has no hard-kill path on the shared runtime, so a per-step create already in flight is what the ending caller's post-pass read of the key remains the net for. Additive to the allocation-boundary predecessor capture: new state fields, new methods, and door checks as separate statements. `test_session.py::TestTheEndingFenceAdmitsNothingUnderTheKey` pins the held front door (cold start and claim), the in-flight invalidation with the hard kill and the re-allocation (fence up when the start returns, and lifted before), the pre-start variant, the spawn read (a start inside `start()` is a spawn in flight; a claim waiting on the turn and a reservation ahead of its spawn door are not), the wait bound, the lift, and the task-session door; the cron module spec records what the reaper does with the fence, including holding it through the record. ## Security: PreToolUse Command Enforcement Command denial is enforced by Kiro Crew's bundled `hooks.py` `PreToolUse` gate, not by injecting `deniedCommands` into kiro agent specs. Agent config generation keeps the bundled security hooks as the immutable base, merges user hooks after them, and strips retired `deniedCommands` / `autoAllowReadonly` fields left by an older installation. Session startup and periodic cleanup do not rewrite agent configs. ## Orphaned MCP Server Cleanup `_cleanup_orphaned_mcp_servers()` kills MCP server processes that survived session teardown. kiro-cli-chat spawns MCP servers (kiro_crew mcp-core/cron, the internal MCP server, slack-mcp) in separate process groups. When a session dies, `killpg` only reaches the kiro-cli process group — MCP servers in other groups get reparented to init and leak memory. **Tracking**: both transports snapshot their descendant PIDs and persist them to `kiro_pids.txt` as `child_pid:parent_pid[:start-id]` entries — `AcpClient` appends them with `_track_child_pids(pids, parent_pid=)`, `AcpRuntime` rewrites its root's whole block with `_replace_child_pids` (see below); the third field is the child's process-start identity (`_pid_start_token`, colon-free, in-process and non-blocking on every platform), omitted only when unreadable at track time. `AcpClient` snapshots in `ensure_ready()`. `AcpRuntime` snapshots in `_snapshot_descendants()`, called repeatedly for a reason the one-shot client scan does not face: the runtime's registered PID is the sandbox launcher, and the tree under it is `launcher -> agent -> agent chat process -> MCP servers`, so the scan runs when the initialize handshake proves the agent came up and then at the end of every `_finish_create_session()` and `load_session()`, because each session start — fresh or resumed — forks another agent process and re-initializes MCP servers the earlier scan could not have seen. Nothing announces a descendant's exit: they are the gateway's GRANDchildren, so there is no `SIGCHLD` to catch and no wait to reap (the gateway does not set `PR_SET_CHILD_SUBREAPER`). Looking is the only way to learn, so each pass re-enumerates the tree and writes the whole answer through `_replace_child_pids(records, parent_pid=, drop=)` — one lock, one atomic rewrite, a `bool` back. One rule governs every field-3 write, and the reason is asymmetric harm. A token that is stale costs a MISSED reap: the sweep compares live against recorded, sees them differ, and prunes the line without killing — a leak, and the sweep still reports it. A token that names the wrong process costs a WRONG KILL: live and recorded agree (both the stranger's), the recycle guard does not fire, and `_cleanup_orphaned_mcp_servers` signals a process that merely reused the number. So when identity is in doubt the answer is always "do not record", never "record whatever holds the number now": > **An identity may be written only if it was captured while that pid was > confirmed to be ours.** Everything else follows from it: - **The writer never reads a live identity.** `_replace_child_pids` persists `_recorded_start_token` of the mapping's value — the token its caller captured — and calls no reader of its own. An append-based writer had no such hazard because it never refreshed field 3 at all. - **A pid in the tree is confirmed by a second walk before its fresh identity is kept.** One released between the enumeration and the capture can be held by an unrelated process when its identity is read; absent from the second walk it is not a descendant of this root, and it is dropped. The window is not closed — crossing it now needs a pid to become a stranger and then become our descendant again. - **A pid outside the tree is kept by IDENTITY, not liveness.** `_escapee_is_still_ours` compares its live start id against the recorded one. Liveness alone is the kill-a-stranger case: a descendant that exited and had its number taken reads as alive. A match keeps the child that left the process group, whose record is the only handle anything has on it; a mismatch, an unreadable identity and an exit are all "not ours" and drop the line. - **The root is bracketed too.** `_root_identity_holds` compares the runtime's own pid against the start id recorded at spawn, before the first walk and again before the write, because a walk from a recycled root enumerates a stranger's whole tree. No recorded identity means nothing is recorded. - **Only the caller's own children are rewritten.** A line is removed when its child pid is named in `records` or in `drop`, never merely for sitting under this `parent_pid`: a root pid is reused like any other, and a descendant that outlived an earlier runtime holding this number is still tracked under it. - **One pass at a time per runtime.** Each pass rewrites the block from what it read, so two overlapping passes let the older read win and drop the newer descendants; sessions start concurrently on a shared runtime, so the pass is serialized on a per-runtime lock. - **The file is written before the in-memory record.** A `False` publishes nothing, so the record never claims a line the file does not carry, and the next pass retries. - **A failure never raises, a cancellation always does.** Losing a snapshot must not fail a live session, so every `Exception` is logged at WARNING and swallowed. A `CancelledError` is not a failure and reaches the caller's cleanup guard, which kills the half-built runtime or terminates the session it owns. On clean shutdown each transport prunes entries by the DESCENDANT's own liveness, never by the root's fate: `AcpClient._reset_state()` and `AcpRuntime._kill_inner()` (through `_prune_dead_descendants`) untrack only the children confirmed gone and log the survivors at WARNING. A child that escaped the group kill by calling `setsid` keeps its entry, because that entry is the only handle the periodic sweep and the next startup cleanup have on it. A root that is already gone when teardown runs is handled on the runtime path by `AcpRuntime._signal_tree` → `_signal_orphaned_runtime_group`: the group id is the root pid (a session leader), and the group's MEMBERS are signalled, each re-verified by start id at the instant of the signal — never the group number, which can be handed to a fresh session leader at any moment. A member counts once `_marked_group_members` finds it carrying this spawn's `KIROCREW_SPAWN_INSTANCE` (the per-spawn token on the root's environment, the one thing a fresh runtime on a recycled root pid cannot share) together with `KIROCREW_SPAWNED` and a runtime argv identity. This is the tree the snapshot cannot cover — a root that died before its first scan — and the tracked sweep cannot either, since nothing was recorded. Linux only; see [acp-client](acp-client.md). Every `/proc//stat` and `status` read on these teardown, sweep and reclaim paths is a BYTES read through `platform_compat` (`read_proc_stat`, `read_proc_status_int`, `linux_pgroup_members`, `proc_child_map`, `get_process_start_identity`), so a process whose name is not UTF-8 -- a multibyte script name cut at the kernel's 15 bytes is enough -- is seen like any other. That covers the root's exit check (`_pid_exited_but_unreaped`: a zombie or a pid that is gone has exited, a present pid whose state cannot be read has not), both group scans (`_pgroup_has_member_besides`, `_marked_group_members`, which takes each member's start id from the same read that admitted it), the session-leader check the orphan-work sweep and the reconciler ask (`_linux_session_leader_alive`: only a leader that is gone or reads another session, a kernel thread's session 0 included, counts as ended), the parent edges (`get_ppid`, `_is_our_descendant`, `_pid_parent_and_token`, `_our_orphan_pids`), the child map, the reconciler's process table, the ages (`_pid_age_seconds`, `_linux_pid_age`) and the RSS reads the recycle ceilings judge by. The two PID tracking files hold ASCII only, so a byte in one that is not UTF-8 is damage. Every read of them, `runtime_reconcile`'s row capture and retraction included, goes through `_read_pid_file_text`, which decodes with `errors="replace"`. The damaged field fails its parse and gets that reader's malformed-entry rule, exactly as an ASCII-garbled one does (a damaged start-id token reads as a mismatching identity, as an ASCII-garbled one does). The tracked snapshot (`_read_tracked_agent_pids`) is incomplete only when a reapable pid field fails its parse. There is no exception: a damaged gateway-pid field is pruned and skipped exactly as an ASCII-garbled one is. No reader, including the `cleanup_orphaned_sessions` boot sweep, lets a decode error escape. If the gateway crashes, the entries remain in the file for the next startup. **Detection**: reads `kiro_pids.txt`, processes only `child:parent` lines (bare PID lines are kiro-cli parents handled by `cleanup_orphaned_sessions()`). If the child is alive but its parent PID is dead, the child is orphaned and killed. Two guards run first. The start identity (entries carrying the start-id field) is subtractive evidence: a live `_pid_start_token` that differs from the recorded one proves the PID was recycled, and the stale entry is pruned without killing. A matching or unreadable token never authorizes the kill by itself -- the tracking file is same-uid-writable, so a forged line must not aim the sweep at an arbitrary process. The kill also needs the runtime ownership gate (`_kill_authorized`; a question the gate cannot answer is a refusal) and a committed teardown barrier (`teardown_barriers`), see [runtime-ownership](runtime-ownership.md). Within that, the candidate is chosen by the reparent heuristic: a genuine orphan reparented to init (pid 1), or still showing the dead parent's PID (kill/reparent race), is killed outright, while a PPid in the same-uid `systemd --user` subreaper set -- the same accepted-parent set `_our_orphan_pids()` uses, computed by the shared `_accepted_subreaper_pids()` -- additionally requires BOTH the `KIROCREW_SPAWNED` environ marker AND positive runtime argv identity (`_tracked_child_has_runtime_identity`: managed agent runtime, MCP entrypoint, or marked launcher shape; unreadable argv fails closed), because every manager-started user service holds the manager's PID as its PPid for its whole life and the marker is tree-wide, inherited even by intentional survivors. Any other PPid means recycled: pruned without killing. **Why not ancestor walk?** MCP servers are spawned in separate process groups and immediately reparented to init (ppid=1) even while the session is alive. Walking the process tree would always conclude they are orphaned. Storing the parent PID explicitly avoids this. **Safety**: - Zero false positives — only kills PIDs we tracked, only when the specific parent session that spawned them is confirmed dead - Dead children are silently pruned from the file - Bare PID lines (kiro-cli parents) are ignored by MCP cleanup **Invocation**: - **At startup**: `cleanup_orphaned_sessions()` calls it after PID-file cleanup - **Periodic**: every cleanup tick, alongside idle session expiry; the tick is `min(max(timeout_secs // 6, 60), MAX_TICK_INTERVAL_SECS)` seconds, or `MAX_TICK_INTERVAL_SECS` (300) when idle expiry is off - **At shutdown**: `cleanup_orphaned_sessions()` on signal/exit ### Unreachable gatewayd reclamation `mcp_gateway.gatewayd` daemons are their own session/process-group leaders (`start_new_session=True`), so a launcher that dies without signalling one (pytest teardown is the common case) leaves it resident forever — `killpg` from the launcher's tree cannot reach it, and the marker-based orphan sweep excludes gateway entrypoints (`_GATEWAY_MARKERS`) because a cmdline alone cannot distinguish a live dev pod's daemon from a dead launcher's. Two layers close the leak, both keyed on the one reachability signal that IS observable: the daemon's `--socket` path. gatewayd creates that socket at bind, so once the path is absent from disk no stub can ever connect again — the process is provably unreachable regardless of who launched it. - **Self-exit (primary, in-daemon)**: `mcp_gateway/daemon/sweepers.py::_socket_liveness_sweeper` stats its own socket path on the idle-sweep cadence, armed only after a successful bind. Three CONSECUTIVE `ENOENT` observations set `stop_event`, taking the same graceful drain as SIGTERM (backends drained and reaped). Any other stat failure (EACCES/EIO) is inconclusive and never counts. POSIX-only — a Windows named pipe has no directory entry to observe. - **Sweep-side reap (defense in depth)**: `_is_sweepable_orphan_gatewayd` is a fourth positive-identity path in the untracked orphan sweep. It overrides the `_GATEWAY_MARKERS` exclusion only for a structural `-m kiro_crew.mcp_gateway.gatewayd` argv whose `--socket` path is gone (NUL-separated argv only — the space-joined `ps` fallback cannot delimit paths safely and fails closed, so the path is effectively Linux-only). `kiro_crew.cli` / `kiro_crew.__main__` stay unconditionally excluded. The kill is TERM-first (`_kill_orphan_gatewayd`) so the daemon drains its own pooled backends, escalating to `killpg` SIGKILL only after the daemon's full `TOTAL_SHUTDOWN_BUDGET_SECS` (shared with the supervisor's SIGTERM→SIGKILL grace, so a correctly-draining daemon is never killed mid-drain), with a cmdline re-verify guarding PID recycling. Same-uid + reparented-to-init candidacy, the age floor, and the kill budget all still apply. ### session_pid sidecar contract (`session_pid_sig.py`) The gateway maps its direct child pid to a session key by publishing `config_dir()/session_pid_.txt` on session claim (writers: `dashboard/chat_runner.py`, `slack/handler.py` — both route through `session_pid_sig.publish_session_pid`, the single legitimate publish path). Because the `.txt` lives in the same-uid agent-writable config dir it is NOT a trust root on its own; publication therefore also writes a `session_pid_.sig` sidecar: - **MAC**: HMAC-SHA256 over `":"`, where *body* is the full published `.txt` content — the session key alone (legacy), `"\n"` (recycle-guarded, below), or those plus the tenant section (shared-runtime, below). The pid is bound into the MAC so one pid's pair cannot be replayed under another pid, and covering the whole body signs the start token too — flipping only the token invalidates the MAC. A legacy body yields a byte-identical message to the pre-token scheme, so mappings signed before the format change still verify. - **Tenant section** (shared runtime): one kiro-cli process hosts several ACP sessions — a `spawn_run` subagent on its parent's runtime, a workflow pool worker — while the mapping names ONE. Publication therefore records the set of sessions sharing the pid, inside the same MAC, and the section is written ONLY above one session, so a 1:1 pid produces byte-identical output to the scheme above and an older reader still parses it. The set comes from `SessionManager.runtime_pids()`, filtered to rows carrying `sid`: that snapshot also appends one row per manager-owned companion RUNTIME whose `key` is display text, and a subagent runtime is frequently the runtime a session is already served by, so counting those rows would record two tenants for a pid hosting one session. The section is size-bounded, so a runtime with many sessions records the true count while dropping members — a short membership is INCOMPLETE, never a closed set. Readers ask two different questions of it and must not share an answer: a reader resolving its OWN identity treats a recorded count above one as a refusal (no single key names it), while a reader VERIFYING a key someone else declared checks membership. Absence of the section is UNKNOWN, never "not shared": a manager that cannot answer must not make readers confident about a pid it has no evidence for. - **Shared-pid attestation**: membership is necessary but NOT sufficient for a declared key, because the `.txt` is agent-readable — a co-tenant could read a sibling's key out of the roster and declare it. A pid demonstrably hosting several sessions therefore also requires an `X-Session-Token` resolving to exactly the declared key (`dashboard/token_auth.py`); the token's MAC uses the agent-unreadable SEL trust root and names one session, which the kernel's proof of the PROCESS cannot do on a shared runtime. A 1:1 pid requires no token: only one session lives there, so there is no other identity to mistake it for. Both admitted paths record which roster shape they judged, so the SEL trail distinguishes a declaration checked against a full membership from one checked against a short. - **PID-recycle guard**: a mapping bound only to the pid *number* would keep verifying for a recycled pid and answer for the new process with the previous owner's session key until the next restart's orphan sweep. Publication therefore appends the process start token (`platform_compat.get_process_start_id` — the same incarnation identity `session_pid.py` records in its `::` sweep entries) as a second line of the `.txt` (a line, not a colon field, because the session key itself contains colons). Both readers dual-parse legacy vs guarded forms and refuse on a PROVEN mismatch — the lenient reader included, since strict-then-lenient fallbacks (`peer_resolve`) would otherwise silently recover the stale attribution. An absent recorded token (legacy file) or an unreadable live token (Windows, exited process) is unknown, never a mismatch — those resolve as before. Same-uid only: a robustness/misattribution guard, not a privilege boundary. - **Key**: a purpose-specific subkey derived from the SEL trust root via a domain-separation label (`HMAC(sel_hmac.key, "kirocrew.session_pid.sig.v1")`). The raw root never signs a sidecar; the sidecar protocol and the SEL audit chain never share a signing key (see `sel.md`). Only `SecurityEventLog` ever *creates* the key file. - **Writes are atomic** (`atomic_write` → `os.replace`): a pre-planted symlink at the predictable paths is replaced, never followed. - **Consumers**: STRICT identity resolvers accept the direct `KIROCREW_HOST_PID` → mapping lookup only via `session_pid_sig.verify_session_pid`, which fails closed to `""` on a missing/short key, missing files, or MAC mismatch, and never raises: the MAC is compared as bytes, so a sidecar whose text is not a hex MAC (non-ASCII included) is a mismatch. A `str` pid the path conversion refuses (a NUL, an unencodable surrogate) is a missing file; any other malformed pid reaches the MAC and is a mismatch. Their callers include the computer-use MCP tools (`mcp_computer.py`, for audit attribution). The session-bound directive tools (see "Stateless session-directive tools" below) still call the strict resolver, but only as a context guard, and bind no effect to the key it returns. `ask_question` is not one of them: it is blocking, and the strict key it resolves IS the attested identity the `/api/agent-ask/*` routes address its card by (contract in `learn-cron-dashboard.md` → "Agent Questions"). Lenient (read-only) resolvers keep reading the mapping without a signature check, but through the same hardened reader (`session_pid_sig.read_session_pid_mapping`: no-follow, regular-file, size-bounded) — `session_pid_sig` owns both the read and write discipline for the file family. The client-side ladder is `mcp_caller.resolve_own_identity` (host-pid + walk, with the co-tenant refusal), which `mcp_core._resolve_session_key` and `mcp_shared._policy_session_key` (the policy walk, called by `_resolve_tool_policy`) delegate to and which also serves `mcp_gateway/stub.py`; the server-side peer walk is `peer_resolve.resolve_peer_identity`. The sidecar is additive. These readers are pid-keyed and therefore answer with the PARENT for a session-sharing subagent, which is why each client-side one reads the per-SESSION token (`session_token_sig`) above these sources; the walk remains the last resort rather than the first answer. - **Unsigned degrade**: if the SEL key is unavailable at publish time the `.txt` is still written (lenient readers keep working) and any stale sidecar is removed — strict resolvers fail closed for that pid. - **Key rotation**: rotating/regenerating `sel_hmac.key` (e.g. snapshot restore, which deliberately excludes the key) invalidates every existing sidecar; strict resolvers fail closed until the next turn's publish re-signs the mapping. Benign and self-healing — no migration step. - **Stale cleanup**: the orphan sweep removes `session_pid_.sig` alongside its `.txt` for dead pids (`session_pid.py`). "Dead" is not `pid_exists` alone: Linux numbers threads from the pid space, so a dead session's pid recycled as a THREAD of an unrelated live process still satisfies that probe and the mapping would survive forever (observed on a host whose pid counter had wrapped: 233 mappings, one naming a 6-day-dead session through a thread). `_prune_stale_session_pid_files` therefore removes a mapping when the pid is unsignalable, OR when it is absent from one `platform_compat.live_thread_group_leaders()` snapshot **and** a per-pid `platform_compat.is_thread_group_leader(pid)` re-read returns `False`. Both helpers answer `None` when the question is unknowable (non-Linux, unreadable `/proc`), and `None` never licenses a removal — so macOS and Windows keep the pre-existing `pid_exists`-only behaviour. Two orderings are load-bearing: the snapshot is taken AFTER the glob (a pid that starts in that window lands IN the set and is retained), and absence from it selects a *candidate* rather than the outcome, so a pid recycled since the snapshot — whose new owner has already republished the mapping at that same path — is retained by the re-read instead of losing a live session's identity. The snapshot costs one `/proc` directory read for the whole pass, which is still work the gateway boot path does not carry: `narrow_with_leaders=False` there per `no-new-work-on-gateway-boot-path` (and on the force-exit handler, which must reach its `os._exit`), while the graceful-shutdown sweep asks for the narrowing. This pass touches only the `session_pid_` family, never the shared `kiro_session_pids.txt` that pass 1 rewrites. - **Cadence** (`SessionCleanup._sweep_session_pid_mappings`): the pass above is reached from `cleanup_orphaned_sessions` — **startup + shutdown only** — so a gateway that kept running held every mapping it published until it restarted, one per released session, and a recycled pid number went on carrying a dashboard slot's key. The periodic cleanup tick therefore calls `_prune_stale_session_pid_files` as well, which is why the `MAX_TICK_INTERVAL_SECS` ceiling above is the other half of the same change: it is what makes this cadence bounded at 300s instead of derived from `session.timeout_secs`. Deliberately NOT hooked onto provider teardown, even though a teardown is the moment a runtime is known dead: `_untrack_session_pid` is synchronous and `AcpClient._reset_state` calls it on the event loop, so a mapping read or unlink placed there is filesystem work over predictable same-uid agent-writable paths in the one place `no-blocking-call-on-event-loop` forbids — a planted FIFO or reparse point would park the gateway. On the maintenance executor the same pass needs no per-syscall bounding at all, and the case a teardown hook could not reach (a gateway that dies without running one) is covered by the same pass. Retraction latency is bounded rather than immediate: a released session's mapping survives at most one tick. The pass keeps its own decision rules unchanged, deliberately — in particular it still retains a mapping whose pid number was recycled to a live process, because that mapping is already refused at READ time (`_pid_recycled` on both the strict and the lenient path) and its file is reclaimed once the unrelated process exits. Adding a prune branch for it would trade a disk saving against the read-then-unlink window in which a new owner's `publish_session_pid` can land, and losing a live mapping costs that session its identity until its next turn republishes. - **Legacy per-pid member-memory bindings** (`SessionCleanup._sweep_member_pid_bindings` → `member_memory_auth.prune_legacy_member_pid_bindings`): `/member-memory-bindings/pids/.json` and `.namespace.json` are records a routing path this version no longer carries wrote one of per agent process. Nothing deleted them and no sweep collected them, so they would accumulate for the install's life. The same periodic tick that retracts session-pid mappings collects them, on the maintenance executor for the same `no-blocking-call-on-event-loop` reason (the directory is same-uid agent-writable). A record goes only when it is BOTH older than `_LEGACY_PID_BINDING_MIN_AGE_SECS` (24 h) AND its pid names no live process: age alone would race a record just written, and a dead-pid test alone would delete a record whose number has been recycled away from a still-running owner, so requiring both is what makes the prune safe against pid reuse in either direction. Symlinks are neither followed nor removed, unrecognised names are left alone (this sweep owns exactly the shape it can attribute to a pid), and each pass is capped at `_LEGACY_PID_BINDING_PRUNE_BUDGET` (2000) so a six-figure backlog drains over passes instead of monopolising one maintenance task. - **Shell audit log cap** (`SessionCleanup._sweep_shell_audit_log` → `shell_audit_log.rotate_shell_audit_log`): the bundled `postToolUse` hook in `config/defaults.json` records every `execute_bash` call as ONE ` BASH: ` line in `/audit.log`, where the payload is the hook-event JSON kiro-cli hands it on stdin with CR/LF stripped. It writes the line in a single `O_APPEND` `os.write` through `python3 -c`, falling back to `printf` when `python3` is absent, and the command bounds nothing — one file, no sibling generation. The bound is applied from the gateway side, not the hook (no portable in-shell `stat` dance, no change for a user who authored their own hook), and the sweep reuses `jsonl_util.rotate_jsonl_at` — rename to ONE `.1` generation replacing any older one, a non-blocking try-lock so two rotators cannot both rotate, a rename rather than a truncate so the hook's `>>` keeps working and no record is cut mid-write, never raises. An over-cap file that stays over the cap after the attempt — the `.1` slot blocked by a planted directory, a sharing violation that never clears — is logged at WARNING, since the primitive swallows its own failure by contract and nothing else would tell a stuck bound from a file under the cap; the line is throttled to one per `shell_audit_log.SHELL_AUDIT_LOG_WARN_INTERVAL_SECS` (an hour) per data home, not one per attempt, because the tick retries for the gateway's whole life and a blocked slot does not clear itself, and it names the usual causes rather than a determined one (the primitive reports no reason, only that the file is still over the cap). The cap is `shell_audit_log.SHELL_AUDIT_LOG_MAX_BYTES` (8 MiB). The steady-state bound on disk is that cap plus at most one tick interval of shell activity of overshoot on the live file, times two generations; the first rotation on an install that already carries an oversized file moves that whole file aside as `.1`, whatever its size, and the next rotation replaces it, so the bound holds from the second rotation on. A default install's shell audit trail is finite — the live file plus one previous generation — and history older than the kept generation is discarded at each rotation. The step runs on every cleanup tick, under the same `MAX_TICK_INTERVAL_SECS` ceiling as the sweeps above, so an oversized file is bounded by the first tick after the loop starts; there is no separate start-of-loop pass, since one would gain at most one tick interval inside the overshoot the design already accepts. It runs on the maintenance executor for the same `no-blocking-call-on-event-loop` reason (the data home is same-uid agent-writable). A file under the cap costs one `stat` and creates nothing — the primitive's lock file appears only when a rotation is actually attempted — so a fresh data home gains no files from this sweep. The cleanup loop is not the file's only guardian: the hook runs inside kiro-cli whichever process launched it, and standalone `kirocrew chat` spawns the same agent with no cleanup loop, so `cli_chat._chat` runs the same sweep once at every chat start, before the backend is spawned (see [cli.md](cli.md), "Interactive Mode"); an install that never runs a gateway is bounded by its next chat start, overshooting by at most one session's shell activity. Not an LLM-facing capability: no MCP tool and no CLI command expose it. - **Member execution routing**: the ordinary session/run owner record carries the immutable member/store snapshot. Strict MCP caller identity still uses the existing transport token and signed `session_pid` publication. No separate member PID publication, process ancestry scan, or member proof token exists. Providers receive the admitted privacy mode before startup; restricted workers do not retain Crew native-transcript copies or raw-frame logs. External engine retention follows that provider's own supported behavior. - **Threat model** (full version in the `session_pid_sig.py` module docstring): file forgery, cross-pid replay, tampering, and symlink planting are blocked; deliberate same-uid impersonation via attacker-chosen env in self-launched processes is out of scope (identical capability exists against env-only resolution) and is tracked as the SO_PEERCRED gateway-authentication follow-up (issue #302). ### Stateless session-directive tools (`session_directive.py`, #755) Codex MCP results wrap text in `rawOutput.result.content`; the ACP parser extracts that text before directive decoding. Native MCP attribution remains on the call. For out-of-band delivery, `event_input_digest` unwraps a tool-call envelope (Codex's `server/tool/arguments`, and the Tool Search deferred `{tool_id, arguments}` form whose `tool_id` must equal the core tool) only when it agrees with the independently identified core tool; [agent-host-contract](agent-host-contract.md) owns the envelope detail. A trusted result whose marker is unavailable may claim that exact session/turn/input-bound pending record. An unmatched final delivery surfaces a NOT-applied notice; acknowledgement alone never confirms activation. The subagent, caller, single-consumption and argument-matching fences still apply. The session-bound MCP tools in `session_directive.DIRECTIVE_TOOLS` (the monitor verbs, `autonudge_stop`, `set_project`, `suggest_followup`, `nothing_to_do`, `reset_conversation`, `chat_tag`) are **stateless**, so they need no per-process identity source and work with MCP-gateway pooling off (`ask_question` is not in the set: it blocks on the `/api/agent-ask/*` routes and returns the answers as its result): the tool validates its arguments and returns a *directive* — a human-readable confirmation line plus a machine-readable marker (`session_directive.encode`) carrying the validated payload and NO session key. The session-aware consumer, `dashboard/chat_runner._run_chat`'s `EVENT_TOOL_RESULT` handler, decodes the marker (`session_directive.decode`) and applies the effect IN-PROCESS against ITS OWN `slot`/`session_key` via `dashboard/session_directive_apply.py`, then strips the marker from the stored transcript. This works with pooling OFF (the default) because the consumer already owns the session. The directives that act on the user's own surface (`_USER_SURFACE_DIRECTIVES`: `set_project`, `reset_conversation`, `chat_tag`) additionally require a user-turn provenance. Subagent isolation is therefore **structural, not cryptographic**: a subagent's tool result flows through the subagent's own runner and can only ever bind to the subagent's session, never its parent's — there is no `/proc` walk to get wrong. The tools still call `_resolve_session_key_strict()`, but only as a context guard to short-circuit sessions where a directive can never be applied (cron/hook/subagent) — not to bind the effect. `ask_question` is NOT a directive: it blocks on the strict `/api/agent-ask/*` routes, which attest the key the tool resolves and address the card to that session's own slot, so a sub-agent (no strict key) is refused before any card is shown. Security properties (enforced in `session_directive.decode` plus the applier): - **Forgery gate keyed on canonical identity**: because the marker is model-visible (it returns as the tool-result text), a directive is honoured ONLY when the tool call was recorded — via kiro-cli's out-of-band `_meta` channel — as an MCP call whose canonical `_meta.kiro.toolName` (with `_meta.kiro.mcpServerName` set) is in `DIRECTIVE_TOOLS`, never the LLM-authored `title`. A shell command titled `monitor_start` whose stdout forges the marker resolves to no directive tool and is ignored; the gate fails closed when `_meta` identity is absent. - **Native sub-agent calls refused**: they surface as flat events in the parent loop but have no independently bindable slot, so the applier declines them. - **SEL audit on every application**: `apply_session_directive` emits a tool-invocation event tagged `source="mcp-directive"` with outcome `success` / `denied` (e.g. a `set_project` sensitive-path block) / `error`, since the effect now runs in the consumer rather than in the tool body or an HTTP endpoint. The applier reuses the SAME effect cores the HTTP endpoints call — `authorize_and_add_nudge` / `authorize_and_update_nudge` / `svc.remove` for the monitor trio, `slot.project` plus the recent-projects save for `set_project`, and `deliver_ws_owners` for `suggest_followup` — so behavior is unchanged. (`ask_question` is not applied here; it blocks and returns the answers as its tool result — full contract in `learn-cron-dashboard.md` → "Agent Questions".) `reset_conversation` is the one directive whose core is not reachable inline: it queues `SessionManager.discard_conversation` on the slot for `chat_runner._consume_pending_reset` to apply at a turn boundary, because the discard is a full provider teardown and the producer is mid-turn — the same deferral `set_project` uses, and the reason the immediate route (`POST /api/chat/slots/{slot}/reset-conversation`) answers 409 on a busy slot rather than tearing down a turn mid-write. It queues the session key THIS TURN ran on, passed in by the consumer, never re-resolved from the slot: `linked_session_key` is mutable, so a cron or workflow injection that rebinds the slot between the request and the consume would otherwise discard whatever the slot points at by then and leave the caller's conversation alone. On the turn path, only the END-OF-TURN consume may apply a discard (`allow_discard`); the two earlier consume points run just before a turn acquires the session, where a teardown lands under a channel turn already streaming on it. The one other caller is `/clear` on a backend with no command channel (`_answer_slash_without_channel`), which runs before any session work and is safe for the reason below: `skip_if_busy`. Even at that boundary it does not assume: the discard goes through `discard_conversation(..., skip_if_busy=True)`, which refuses under the same session lock that pops the session, mirroring `reset`'s own guard. Probing from the consumer and tearing down afterwards would leave a window in which a channel message acquires the session's semaphore and begins streaming a reply the teardown then destroys — and the semaphore is the stricter signal anyway, since `provider.has_active_turn()` cannot see a turn holding the semaphore with no prompt in flight yet. A refusal returns False and changes nothing, replay flag and session map included, so the consumer leaves the flag armed for a later boundary. The sid clear runs in the SAME tick as the pop, with no await between them — deferring it past the shutdown awaits lets a concurrent channel turn map a SUCCESSOR session under the key while the old provider is still shutting down, and the clear then erases the successor's pointer instead of the discarded one. Sub-agent children are the other wait — `discard_conversation` releases the shared runtime they run on, so a running or queued child, or an in-flight completion-event delivery, also leaves the flag ARMED rather than killing the child's work. Both that consume and the route's 409 read one predicate, `chat_utils.subagents_attached_async`, so the two cannot drift. The queued flag is in-memory slot state: a gateway restart while it sits armed drops the reset the confirmation promised, which is accepted rather than persisted — the cost is one un-applied reset the caller can ask for again, against durable state for a transient intent. `set_project`, `reset_conversation` and `suggest_followup` additionally require structural user-turn provenance: injected cron, task-runner, sub-agent, auto-nudge, orchestration, app-authenticated unattended turns, and app-authored Spec Builder seed/handoff prompts cannot retarget a borrowed destination slot even when its session key is user-facing — nor, for the last of the three, post a follow-up card into a human's session on the strength of a dashboard tab they merely inherited. `suggest_followup` is the case where the surface gate and the origin gate come apart: `_DASHBOARD_ONLY_DIRECTIVES` asks only whether an open tab EXISTS, which a cron or sub-agent turn riding a user's slot satisfies without a human having asked for a card, so membership in `_USER_ORIGIN_DIRECTIVES` is what makes the tool's own documented refusal true. Unlike `reset_conversation`, `suggest_followup` has no self-wake carve-out: only a turn a person sent can post a card, so the session's own sub-agent synthesis turns and monitor wakes are refused too. Spec Builder rejects app-token message and decision submissions before they can enter its human-provenance relay or durable decision ledger. Queue entries preserve this provenance, replacement text adopts the editor's provenance, and mixed or untagged merges fail closed. Gateway-off (the default topology this targets), the model's tool result is the tool's OWN returned line delivered over kiro-cli's MCP pipe; the applier's confirmation string and SEL audit are recorded on KiroCrew's own surfaces (transcript / WS / hooks) and do NOT rewrite the model's tool result. Each tool therefore phrases its own message as a *request* that the consumer applies (and may refuse — no interactive session, invalid/sensitive path, capped/paused loop) rather than asserting the effect already landed. ```mermaid sequenceDiagram participant M as Model participant T as MCP tool (kirocrew-core) participant R as chat_runner._run_chat
(EVENT_TOOL_RESULT) participant A as session_directive_apply M->>T: call e.g. monitor_start(args) T->>T: validate args (resolves NO session identity for the effect) T-->>R: tool result = human line + directive marker R->>R: decode(result, canonical _meta.kiro.toolName) Note over R: forgery gate — canonical name in DIRECTIVE_TOOLS,
not the LLM title; native sub-agent calls refused R->>A: apply_session_directive(slot, session_key, kind, args) A->>A: run effect core against the consumer's OWN slot A-->>R: confirmation string + SEL audit (source="mcp-directive") R->>R: strip marker from stored transcript ``` ### Orphan Sweep Active Set The periodic sweep of `kiro_session_pids.txt` (which kills tracked kiro-cli PIDs no longer in `self._sessions`) builds its active set as the union of `_collect_active_pids(self._sessions)` + `_pool_pids()` + `_in_flight_pids()` + `_companion_runtime_pids()`, re-checked against the same union in phase 2 before any kill. `_companion_runtime_pids()` returns the live PIDs of `self._subagent_runtimes` (companion runtimes multiplexing a parent session's subagents) and `self._bg_runtime` (the multiplexed `_bg` runtime), each guarded on `is_alive()` — only alive runtimes are shielded, so dead ones are still reaped. **Failure it fixes**: since the `AcpRuntime` unify, *every* runtime records its PID in `kiro_session_pids.txt` at spawn. These two runtime kinds live outside `self._sessions`, so before this union the sweep saw their live PIDs as untracked orphans and SIGKILLed them mid-chat (surfacing as `process exited (rc=-9)`). **The active set is a pre-filter, not the authority.** It is a set gathered an event-loop hop before the decision, so it cannot see a party that started using a pid after it was read. What authorizes a signal on the periodic PID sweep (`_sweep_periodic_pids`) and on the orphan MCP sweep (`_sweep_untracked_mcps`) is the runtime ownership gate, asked once per pid at the decision point: `SessionCleanup._kill_authorized` calls `authorize_runtime_kill(pid, reason=..., caller="session_cleanup sweep")` for each candidate that survived the phase-2 re-check. The gate refuses while either ownership table claims the pid: the lease table (`RuntimeOwnership`) or the tenancy table (`RuntimeTenancy`). A gate that raises is a refusal. A refusal withholds that pid only: it is dropped from the confirmed list, the rest of the pass continues, and the pid is asked about again on the next tick. Both verdicts are audited (`allowed` / `refused`), and `allowed` is never written as `killed`; the kill phase records what the signal actually did. The confirmed pids then pass through `teardown_barriers(confirmed, who="Sweep")`, which commits a tenancy teardown barrier for each one immediately before the kill phase runs and releases it when the phase exits, whether it returned or raised. The barrier closes the window between the gate's verdict and the signal (a thread hop, a pid-file read, token reads, a descendant walk): a tenant that claims the pid inside that window makes the barrier refuse it, and only the pids the barrier granted are signalled. The rest are left for the next tick. A new sweep that signals a pid must ask the gate per pid and take the barrier the same way; consulting the active set alone is the defect this section describes. The gate, the two tables and the barrier are specified in [runtime-ownership.md](runtime-ownership.md#the-one-kill-gate), and the kernel-against-registry reconciler that runs last on the same tick, with its `unowned_alive` / `owned_dead` counters, in [runtime-ownership.md](runtime-ownership.md#reconciliation-the-kernel-against-the-registry). ### Cross-platform process management (platform_compat) ### Reclaim identity: a projected marker set, and a subtractive token `kiro_session_pids.txt` entries are swept by `_sweep_pid_entries` (periodic, in two phases) and `cleanup_orphaned_session_roots` (run from the periodic cleanup loop; the startup/shutdown reclaim is `cleanup_orphaned_sessions`). What identifies the process in both is `_is_managed_agent_process(pid)` — does this PID still name the kind of process the entry described — plus each arm's own condition: a dead owning gateway, and for a token-less entry a reparent to init or to the dead gateway. On the periodic sweep that is not the whole authority: a pid that passes it still needs the ownership gate's per-pid allow and a committed teardown barrier before it is signalled (see [Orphan Sweep Active Set](#orphan-sweep-active-set)). `cleanup_orphaned_session_roots` and `cleanup_orphaned_sessions` do not ask the ownership gate: they reclaim entries whose owning gateway is dead, and the identity test and arm conditions here are the whole of their authority. That gate is per-TOKEN and exact wherever a command line can be read (Linux `/proc`, macOS `ps`), never a substring of the whole line: the projected names include three-character ones, and `dsh` sits inside `friendship`, `goose` inside `mongoose`. The tokens allowed to answer are chosen by POSITION: `argv[0]`, and — only when `argv[0]`'s basename is `node`, `nodejs` or `node.exe` — `argv[1]`, which is the script slot of a Node-hosted adapter. `argv[1]` exactly, never "the first token that does not look like a flag". Crew launches an adapter as `[node,