From f8b4d6a0878e1a67d5be02591d5dedce16108274 Mon Sep 17 00:00:00 2001 From: Robert Noack Date: Mon, 31 Aug 2026 01:15:16 +0000 Subject: [PATCH] fix(session): refuse stale sessions at claim time and surface refused clears A claim could reuse a session bound to a superseded project or agent, so a turn's relative writes landed in the directory the user had just moved away from. The refusal is now raised at claim time and a refused clear is reported instead of silently reading as done. Re-roll: unreachable timing assertion on a loaded CI runner --- .../modules/persistent-agent-channels.md | 4 +- docs/system-specs/modules/session.md | 432 +++- src/kiro_crew/acp/client.py | 4 +- src/kiro_crew/acp/runtime.py | 6 +- src/kiro_crew/acp/session_handle.py | 4 + src/kiro_crew/acp/session_provider.py | 15 + .../builtins/spec_builder/backend/runtime.py | 5 +- src/kiro_crew/channel.py | 281 ++- src/kiro_crew/cli_server.py | 4 +- src/kiro_crew/config/loader.py | 12 +- src/kiro_crew/config/paths.py | 71 + src/kiro_crew/dashboard/channel_slots.py | 4 + src/kiro_crew/dashboard/chat_fork.py | 3 + src/kiro_crew/dashboard/chat_handlers.py | 448 +++- src/kiro_crew/dashboard/chat_orchestrator.py | 21 +- src/kiro_crew/dashboard/chat_persistence.py | 20 +- src/kiro_crew/dashboard/chat_runner.py | 309 ++- src/kiro_crew/dashboard/chat_utils.py | 57 + src/kiro_crew/dashboard/cron_inject.py | 3 +- src/kiro_crew/dashboard/handlers/cron.py | 3 +- src/kiro_crew/dashboard/handlers/side.py | 2 +- src/kiro_crew/dashboard/handlers_channel.py | 215 +- .../dashboard/session_directive_apply.py | 28 +- src/kiro_crew/dashboard/state.py | 76 +- src/kiro_crew/dashboard/workflow_inject.py | 4 +- src/kiro_crew/history.py | 5 +- src/kiro_crew/session.py | 141 +- src/kiro_crew/session_allocation.py | 579 ++++- src/kiro_crew/session_lifecycle.py | 65 +- src/kiro_crew/slack/gateway.py | 6 +- test/chat_test_helpers.py | 18 + test/test_acp_session_provider.py | 28 + test/test_channel.py | 89 + .../test_channel_clear_context_idle_member.py | 431 ++++ test/test_channel_orphan_thread.py | 73 + test/test_channel_prompt_busy.py | 130 ++ test/test_chat_fork_cleared_project.py | 83 + test/test_chat_runner_coverage.py | 1115 +++++++++- test/test_chat_slack.py | 7 +- test/test_chat_slot_key_settling.py | 257 +++ test/test_chat_slot_project.py | 617 +++++- test/test_chat_slot_switch_atomicity.py | 1673 +++++++++++++- test/test_chat_slot_unsettled_publish.py | 196 ++ test/test_chat_slot_unsettled_reset.py | 89 + test/test_cli_server_more_coverage.py | 19 +- test/test_dashboard_approval.py | 23 +- test/test_dashboard_chat.py | 137 +- test/test_dashboard_chat_handlers_coverage.py | 3 + test/test_eager_spawn.py | 84 +- test/test_handlers_channel_clear_context.py | 601 ++++- test/test_mcp_core_set_project.py | 73 + test/test_session.py | 1924 +++++++++++++++++ test/test_session_control.py | 60 + test/test_session_pool.py | 134 ++ test/test_side.py | 65 + test/test_spec_builder_routes_coverage.py | 36 + test/test_subagent_delivery_ttl_anchor.py | 2 +- test/test_subagent_scale.py | 6 +- .../agent-switch-workspace-unavailable.html | 11 + .../agent-switch-workspace-unavailable.tsx | 64 + .../capture/clear-context-busy-refusal.html | 11 + .../capture/clear-context-busy-refusal.tsx | 260 +++ .../scripts/capture-agent-switch-notice.mjs | 79 + .../capture-clear-context-busy-refusal.mjs | 188 ++ website/src/App.tsx | 9 +- website/src/components/AgentSwitchNotice.tsx | 52 + website/src/components/ErrorNotice.tsx | 40 +- website/src/i18n/locales/bn.json | 34 +- website/src/i18n/locales/de.json | 34 +- website/src/i18n/locales/en-XA.json | 18 +- website/src/i18n/locales/en.json | 4 +- website/src/i18n/locales/en.manual.json | 14 +- website/src/i18n/locales/es.json | 34 +- website/src/i18n/locales/fr.json | 34 +- website/src/i18n/locales/hi.json | 34 +- website/src/i18n/locales/it.json | 34 +- website/src/i18n/locales/ja.json | 34 +- website/src/i18n/locales/ko.json | 34 +- website/src/i18n/locales/pt.json | 34 +- website/src/i18n/locales/ru.json | 34 +- website/src/i18n/locales/zh-CN.json | 34 +- website/src/pages/ChannelPage.tsx | 362 +++- website/src/test/AgentSwitchNotice.test.tsx | 115 + website/src/test/App.test.tsx | 7 +- .../test/ChannelPage.clearContext.test.tsx | 588 ++++- website/src/test/agentSwitchFeedback.test.ts | 22 +- .../src/test/clearContextMessagesKept.test.ts | 63 + .../src/test/clearContextRetryVerb.test.ts | 31 + website/src/utils/agentSwitchFeedback.ts | 32 + 89 files changed, 12601 insertions(+), 543 deletions(-) create mode 100644 test/test_channel_clear_context_idle_member.py create mode 100644 test/test_channel_orphan_thread.py create mode 100644 test/test_chat_fork_cleared_project.py create mode 100644 test/test_chat_slot_key_settling.py create mode 100644 test/test_chat_slot_unsettled_publish.py create mode 100644 test/test_chat_slot_unsettled_reset.py create mode 100644 website/capture/agent-switch-workspace-unavailable.html create mode 100644 website/capture/agent-switch-workspace-unavailable.tsx create mode 100644 website/capture/clear-context-busy-refusal.html create mode 100644 website/capture/clear-context-busy-refusal.tsx create mode 100644 website/scripts/capture-agent-switch-notice.mjs create mode 100644 website/scripts/capture-clear-context-busy-refusal.mjs create mode 100644 website/src/components/AgentSwitchNotice.tsx create mode 100644 website/src/test/AgentSwitchNotice.test.tsx create mode 100644 website/src/test/clearContextMessagesKept.test.ts create mode 100644 website/src/test/clearContextRetryVerb.test.ts diff --git a/docs/system-specs/modules/persistent-agent-channels.md b/docs/system-specs/modules/persistent-agent-channels.md index 0d471f3384b..57f5489ba3c 100644 --- a/docs/system-specs/modules/persistent-agent-channels.md +++ b/docs/system-specs/modules/persistent-agent-channels.md @@ -94,7 +94,9 @@ Closing a channel cancels live agent tasks, broadcasts the close, and removes it `api_channel_clear_context` resets either one agent session or every channel-agent session. An agent-scope reset preserves shared messages and exchange counts; an all-scope reset also clears both, persists the channel, and broadcasts `channel_context_cleared` so other browser clients discard stale messages. -The handler does not take a per-channel lock. A post concurrent with an all-scope reset can be cleared by the reset, and an in-flight approval future is not cancelled by the handler; it resolves through the agent task after the session reset. This is the current concurrency gap, not a guarantee of serialized channel mutation. +The clear runs under the channel's log lock, which `post` also takes, so a post cannot reach the inbox mid-clear and one already queued refuses its member rather than being wiped unacknowledged. An in-flight approval future is still not cancelled by the handler; it resolves through the agent task after the session reset. + +Thread pointers resolve under that same lock, and a message carries `thread_id` and `reply_to` as a PAIR: both set, or neither. `reply_to` is knowable only from the parent, so a reply whose parent is gone by the time the append runs -- an all-scope clear empties the index under this lock -- posts TOP-LEVEL with both fields cleared. Retaining the id there would store a pointer no reader can resolve beside an empty `reply_to`, and dropping the message would lose content its sender was told had been accepted. Pinned by `test_channel_orphan_thread.py`, whose third case asserts the pair is never half-set. ## Security diff --git a/docs/system-specs/modules/session.md b/docs/system-specs/modules/session.md index ff291f66c75..c203a7f95cc 100644 --- a/docs/system-specs/modules/session.md +++ b/docs/system-specs/modules/session.md @@ -424,13 +424,435 @@ send time. 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)`. Its contract: it returns `True` with + Because a session can be recycled/removed, its backing process can die, or its + project directory can change while a caller waits on the semaphore, every reuse + path re-checks identity + liveness + BOUND CWD AFTER acquiring it, through the + single shared helper + `_reacquire_and_validate(key, sess, *, cwd=None)`. 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). Cancellation while parked on + `_evict_stale_session` and cold-starts). The cwd dimension is validated HERE and + not at the reuse decision, because that decision runs before the semaphore is + claimed and evicting there could tear down a turn still streaming. Both sides of + that comparison are normalized through `str(Path(...))` — the request AND the + provider's reported binding — so the result cannot depend on which side happened to + be canonicalised already, and a caller naming a stable directory (trailing slash, + doubled separator, or a platform whose `Path` rewrites the separators) cannot churn + a warm session every turn. A caller + passing no `cwd` states no requirement, so it cannot trigger a mismatch — which is + why a teardown that has to be REFUSED additionally ARMS THE KEY via + `mark_retire_on_next_claim`: a cleared project cannot be expressed as a cwd + requirement, so the arm, not the directory, is what stops the next claim being handed + the stale binding. The arm is keyed by STRING rather than by session object because a + cold start holds no registry entry until it finishes, so "nothing registered" can mean + a provider bound to the pre-change directory is already on its way; the key is + consumed as it is read, costing one cold start rather than refusing that key forever. + + Alongside the arm, each key carries a MONOTONIC GENERATION (`RetireArm.generation`), + bumped by every project change — whether it arms (`mark_retire_on_next_claim`) or + not (`note_project_change`, which the agent- and workspace-switch handlers call + because they commit a new project and tear the session down directly, recording the + directory they committed so an evicted start's RETRY binds that rather than the one its + own frame carried). A cold start + snapshots the generation before its first `await` and is refused at registration if + the key has moved past it. This exists because the DIRECTORY a claim states cannot + order two claims: a start begun before the change and a slot recreated under the same + name after it both name something other than the armed target, so only the generation + separates them. Supersession of the arm ITSELF is a different and simpler mechanism: both + producers write the arm and bump the generation in one statement block, so a later change + to the same key OVERWRITES the earlier arm in the map and there is no stale entry for a + stamp comparison to find. An earlier version carried a per-arm generation stamp and a + `_live_arm` reader that dropped superseded entries; that branch was unreachable by + construction and has been removed, so nothing here reads a stamp. + + Why not fold staleness into the KEY instead. The obvious alternative removes this whole + mechanism by construction: put a binding epoch in the folded session key, so a stale start + registers under a dead identity and needs no arm, no transfer and no settle loop. It loses + on the key's ownership, not on elegance. The folded key is a PERSISTED, externally owned + identity: a channel member's `agent.session_key` and a cron job's `session_key` are written + to disk and re-read by a later process, and a Slack-born key is a thread timestamp its own + surface maps back to. Appending an epoch changes the key every time a project moves, so + every record already persisted under the old spelling stops resolving — and the failure is + silent, because a key that no longer folds onto a live session reads exactly like a session + that has ended. `_fold_key` also already carries a compatibility burden for that reason: + it resolves exact, then `canonical_key`, then `legacy_key` onto one live entry. An epoch + adds a second unbounded dimension to that same lookup, and every alias spelling would need + its epoch to agree. + + It also cannot cover the case the arm exists for. A cold start caches its resume SID BEFORE + it registers, so at spawn time there is no entry to name — an epoch would have to be decided + by the spawner and could be superseded during the start it was meant to fence, which is the + race itself rather than a fix for it. The per-key arm is consulted at REGISTRATION, after the + change has landed, which is why it catches that start. So the arm map is the compatible + shape, not the preferred one: it keeps the key a stable identity that persisted records and + external surfaces can still resolve, and pays for that with a fencing protocol whose + producers must self-classify. `_ARM_KEY_SETTLE_PASSES` and the park flag are the cost of + that choice and are named here so a later reader can price the alternative honestly. + + This counter is NOT the existing `ownership_generations`, and the two cannot be unified, + for two reasons visible in their keys. `advance_ownership_generation` buckets by + `_generation_key`, i.e. `canonical_key(key)`, a bucket deliberately SHARED by a key's + canonical and legacy Slack aliases; the arm's counter is per FOLDED key. Sharing one + counter would therefore let a project change on one alias refuse an in-flight start on a + sibling alias whose project never moved. They also advance on unrelated events: ownership + advances when a logical key CHANGES HANDS, while this one advances when a key's PROJECT + changes, and it must survive `spend()` — the arm's directory and agent drop while the + counter stays, because a start compares against it at registration. Folding them together + would make an ownership transfer read as staleness to starts that were never stale, and a + project change read as a change of owner to every claim that consults ownership. + + The spend/keep matrix is asymmetric and deliberate, and it follows from what each + teardown does to the SESSION MAP rather than from a per-path preference. The arm names + a directory a SUCCESSOR must bind, so it may only be dropped where no successor of that + slot can arrive. A refused reset never spends the arm (the reason for the teardown has + not gone away); a landed reset does not either (the arm is keyed on the target + directory, so the eager respawn is reused rather than torn down). `destroy` DELETES the + session-map entry and `close_all` ends the process, so both spend it — the only claim + left for it to apply to would be a different slot recreated under the same name, which + would silently inherit the previous project. `remove` and `remove_if_unclaimed` PRESERVE + that entry, so they spend nothing: the arm is still owed to a real claim, and it doubles + as the retry target for a start the cleanup evicts, whose own frame carries only the + pre-change directory. That keeps the arm alive past the SLOT, though — the close and sweep + paths both run `remove` — and a recreated slot only overwrites it if the user happens to + change its project, which a fresh tab accepting the default never does. So the arm cannot + be left for the next occupant to supersede; slot CREATION drops it. + + Two allocation-layer primitives drop it. `spend_retire_arm(key)` is called only from + `destroy`, whose contract is a teardown that ends the slot generation. + `supersede_arm_for_new_slot(key)` is called only from slot creation's mint path, on the + grounds that a freshly minted slot has never been armed, so any arm on its key belongs to + a previous occupant; slot REUSE returns earlier and so keeps the retry target `remove` + preserves the arm for. It bumps the GENERATION rather than dropping it, because clearing + would let a start still in flight from the previous occupant compare equal to a fresh + key's zero and bind here. The keep-side paths deliberately call nothing: keeping the arm + IS the absence of a call, so a no-op primitive existing to be countable by a census would + be dead weight. What ratchets a NEW teardown path onto one side is + `test_only_slot_ending_teardowns_spend_the_retirement_arm`, which asserts the keep-side + as an absence. The key's GENERATION survives every teardown here, which is what still + refuses a start already in flight. + + **The `linked_session_key` writers are serialized against the settle.** The settle was + probe-then-act: it re-read `linked_session_key` a bounded number of times + (`_ARM_KEY_SETTLE_PASSES`), which damped the race without closing it, because a rebind + landing after the last pass stranded the arm on the abandoned key. That is the shape this + repo's own rule rejects — a probe followed by a separate act cannot protect a resource whose + state changes between the two — so it is closed here rather than recorded as owed. + `settling_key` excludes the whole settle-and-arm region; `bind_linked_session_key` is the + single writer and PARKS a rebind arriving inside it; `key_rebind_deferred` reports that one + is pending, and an unsettled settle publishes no arm and RETRACTS the source arm rather than + leaving it owed to a binding nobody is on. All eight assignment sites across five modules — + `dashboard/handlers/cron.py`, `dashboard/chat_persistence.py` (three), `dashboard/state.py` + (two), `dashboard/workflow_inject.py` and `dashboard/cron_inject.py` — route through that + single writer. + + Deliberately NOT the slot lock, for two independently disqualifying reasons: it is not + reentrant and one transfer already runs inside it, so a second acquisition would never + return; and seven of the eight writers are synchronous functions, which cannot acquire an + asyncio lock at all. Deferring rather than blocking also cannot deadlock, which a lock + taken by a writer that already holds it could. + + WHAT REMAINS, and what does not. Writer-versus-writer ordering is deliberately not imposed + and is not owed: a binding is a latest-value fact, so writers racing outside a region + resolve last-write-wins and only the newest key parked inside one survives the unwind, which + is the intended semantic in both cases. A NINTH surface sets the field without naming the + attribute — `get_or_create_slot`'s `linked_session_key` argument, which every channel and + cron surfacing passes. It needs no guard because the factory returns an existing slot before + reaching the bind, so it can only bind a slot it just minted, where no arm can yet be + settling. TRIGGER: any new writer must route through `bind_linked_session_key`. The writer + census in the settling tests fails a module that reaches the field by a dotted assignment + without it, and is structurally blind to the keyword form, so that surface carries its own + control rather than relying on the census. + + WEIGHED ALTERNATIVE — every claim states its own requirement. Instead of a store the + producer writes and the claim reads, each claim could carry the cwd and agent it requires and + be refused when the live session does not match; `CWD_CLEARED` already makes "cleared" an + expressible requirement, so the vocabulary exists. That shape is genuinely smaller in state: + it removes both pending maps and leaves only the generation counter. + + It is NOT taken here, for one reason that is structural rather than a matter of taste. The arm + covers a session that is NOT YET REGISTERED: a cold start holds no registry entry until it + finishes, so a claim-time comparison has nothing to compare against while a provider bound to + the pre-change directory is already on its way. The requirement would be stated by the very + claim that is in flight, which is the window the arm exists to close. A claim-stated + requirement therefore cannot replace the arm; it can only replace the part of it that a + registered session already covers. + + Two costs beside that. Every claim site becomes a writer of the requirement, and the channel + dispatch in `messaging/dispatch.py` calls `get_or_create` with no cwd at all today, so the + blast radius is every caller rather than one subsystem. And a requirement stated per claim is + re-derived by each caller, where the store derives it once at the moment of change. + + What would make the alternative win: a registry that records a cold start BEFORE its provider + binds, so a claim has something to compare against for the whole window. With that in place the + arm store's remaining job is only the generation counter, and this section should be revisited. + + **OWED FOLLOW-UP — record a cold start in the registry BEFORE its provider binds.** This is + now the ONLY open root cause the arm store compensates for rather than removes; the other — + the unserialized `linked_session_key` writers — is closed above. Closing this one at cause + means recording the key when the start is REQUESTED, not when its provider binds, so a claim + has an entry to compare against for the whole window. Until it lands, a claim arriving inside + that window has nothing to compare against, so correctness rests on the producer having armed + before the start was requested. TRIGGER: this is owed before any new caller can start a + session outside the arming producers named here. Landing it would shrink the arm store's + remaining job to the generation counter alone, which is why no further producer should be + added to the arm store before it: each one that is makes this replacement more expensive, + not less. + + Two properties of the arm's LIFECYCLE are load-bearing and neither is local to the + allocation layer, so both are recorded here rather than as comments at their sites. + It is raised by the PRODUCER, synchronously. A project change defers the RESET — the + endpoint is reachable over loopback HTTP from inside the kiro-cli process group, so an + inline teardown would `killpg` the caller — but deferring the ARM as well would leave a + window: the consumer runs behind the eager task's 1.5s debounce, and a channel turn + arriving inside it states no directory, so the claim-time cwd check cannot fire and the + arm is the only thing that would refuse the pre-change session. Arming is in-memory + bookkeeping and carries none of the `killpg` risk that forced the reset to be deferred, + so the two are deferred separately: the reset waits, the arm does not. + + It is satisfied by a BINDING, not only by a stated directory. A claim that names no cwd + cannot state agreement with the armed target, and channel turns are exactly that shape — + the messaging dispatch builds its `get_or_create` kwargs with `model` and nothing else. + Gating satisfaction on a stated cwd therefore left the arm permanently unsatisfiable for + that consumer: every channel turn read as retire-applies, evicted, and cold-started, so + the change cost a cold start on EVERY turn instead of the single one it is supposed to + cost. A registered session already bound to exactly the armed directory is the successor + the arm was waiting for, whether or not the claimant restated that directory. + + Which makes the two producers NOT interchangeable, and this is the sharp edge. Both + record the committed directory and bump the generation; `mark_retire_on_next_claim` also + flags the REGISTERED session via `retire_on_identity_change`. Because satisfaction is a + directory test, a change that moves the DIRECTORY is fully expressed by the arm alone — + that is `note_project_change`. A change of IDENTITY is not: an agent switch on a + project-scope agent keeps `slot.project`, so the arm names the directory the live session + is already bound to, a cwd-less channel claim reads it as satisfied, and the + switched-away agent serves the next turn. The identity flag is the only part of that a + matching directory cannot express, and it survives the busy-deferred reset that leaves + the old session alive. So: directory moved → `note_project_change`; session must be + replaced whatever its directory → `mark_retire_on_next_claim`. + + Two arms, two questions -- but ONE spend. `cwd_satisfied` and `agent_satisfied` are + independent predicates, and each is the only thing that can answer its own half. They are + not independent SPENDS: an arm is dropped exactly when the claim is ACCEPTED, never one + half at a time. Spending each on its own answer loses the directory requirement on a + claim the same frame is about to REFUSE, and the case is ordinary rather than exotic. A + project-scope agent switch keeps `slot.project`, so it arms the directory the session is + already bound to plus the agent a successor must run; a cwd-less claim then satisfies the + directory half by its binding while the identity half cannot be satisfied at all. That + claim is refused -- and if the directory arm went with it, the SUCCESSOR is unguarded: it + can bind any directory, nothing refuses it, and its relative writes land outside the + selected project with no recovery path. + + What an empty cwd resolves to is per-SESSION, not shared. The provider factory resolves a + falsy `cwd` to `workspace_root()/`, which is a different root from + `config_dir()/"workspace"`, so `resolved_cwd` has to be told the key or it answers a + directory no provider ever binds. Two harms follow from the divergence, and they are the + two `default_workspace_dir` exists to prevent: a cleared-project claim never matches its + own binding, so the slot cold-starts every turn or exhausts its retry budget and wedges; + and anything that BINDS that answer puts sessions meant to be isolated in one directory, + where their relative writes overwrite each other. `session_default_cwd` is the one symbol + both the factory and the resolver go through, so the agreement is an invariant rather than + a convention -- keyless callers keep the shared answer, for the paths that genuinely share + one workspace. + + A REBIND is the third producer shape, and it needs a MOVE rather than a write. The + deferred reset arms the key the producer could see; if the slot rebinds before the + consume, its turns run under another key and both facts are true at once. Arming the + live key alone leaves the abandoned key's arm in the map, and nothing drops it — an arm + is cleared by the claim that satisfies it, and no claim arrives under a key the slot + left. That residue is not inert, because the map is keyed by STRING: a later session + registered under the same string, a channel re-link reusing the id or a recreated slot + of the same name, reads an arm naming a directory chosen for a binding that no longer + exists. So `_consume_pending_reset` calls `transfer_retire_arm`, which arms the live key + and clears the abandoned one in one synchronous step, carrying the agent requirement + across because only the arm states which agent a successor must run. It is distinct from + `spend_retire_arm`, whose contract is a teardown that ENDS the slot generation and so + drops an arm no successor is owed; here the arm is still owed and only its ADDRESS was + wrong. The abandoned key's GENERATION stays, for the reason `spend_retire_arm` keeps its + own: that counter refuses a start still in flight under the old key, which a rebind makes + more likely rather than less. + + WIRE CONTRACT CHANGE — a project set during a rebind now SUCCEEDS. `api_chat_slot_project` + previously refused with `409` when the slot rebound between the request's gate and its commit, + on the ground that arming the new key would arm one the app gate never authorized. It now + answers `200` and transfers the arm, because that ground is addressed rather than assumed: the + live key is re-authorized through `_app_cancel_denied` before any arm moves, and a denial + retracts the source arm so no arm survives on either key. A caller that treated the `409` as + "retry the set" therefore sees a success where it previously retried; the retry was always + going to succeed on the next attempt, so the observable difference is one fewer round trip, not + a different final state. An unauthorized rebind still refuses. + + An EQUIVALENT same-key re-arm keeps the generation. `_consume_pending_reset` is re-entered + by the deferred-reset retry every few seconds for as long as sub-agents stay attached, and + each pass transfers the arm onto the SAME key with the SAME target. Bumping the generation + there refuses a cold start that has not finished resolving its own model, so a slow start is + rejected on every attempt until the path gives up — the counter meant to refuse a STALE + start starves a current one instead. Equivalence is deliberately narrow: same folded key AND + the already-armed target equal to the requested one. A different target on the same key is a + genuine change and must supersede, and the agent needs no comparison because the carried + value is read from that same key. The registered session is still flagged on the preserved + path — a session that registers between the first arm and a retry pass must honour the arm, + which is the arm's whole purpose. + + A ROLLBACK is a producer too, and it is the one that gets the identity wrong. The agent + switch arms BEFORE its awaits, so an abandoned switch leaves an arm naming a binding the + slot no longer holds; leaving it there makes the arm permanently unsatisfiable, so the + rollback re-points it. The subtlety is WHICH binding to name. The rollback unwinds each + field on its commit token's IDENTITY, never on value, so an unlocked writer — the in-turn + `/agent` directive, `members`, `openai_compat` — that wrote during the awaits keeps its + value and the rollback stands down for that field. The arm must then name the PRESERVED + agent, resolved, because the registration matches on `kiro_agent or slot.agent`: arming + the prior agent's target refuses the next cwd-less claim for an identity the slot never + names, and the retry re-points the session to an agent nobody selected. This is not a + remote combination — the rollback path is reached precisely BECAUSE a turn is in flight, + which is exactly when an in-turn directive lands. The same rule already governs the + metadata restore beside it, which writes post-rollback `slot.agent` rather than + `prior_agent` for this reason. Only when the rollback actually RESTORED the field is the + prior agent's resolution the right answer. + + What the arm falls back to when the project is EMPTY. Both arm sites name + `slot.project or `, and the post-switch project is legitimately empty: + `default_project_dir(workspace)` answers `""` for a workspace directory that is missing or + sensitive (its own `is_sensitive_path` guard in `config/loader.py`). The fallback must + therefore be the CLEARED + per-session default, resolved through `resolve_arm_cwd(key, CWD_CLEARED)` — never a + project, and in particular never the PRE-switch project. Resolving it from the pre-switch + project made an agent switch into such a workspace arm the directory it was abandoning, so + the next cwd-less claim bound the old repository and relative writes landed there with + nothing to signal it. The resolution is UNCONDITIONAL: `slot.project` has writers that take no lock, so a clear + landing during the handler.s awaits empties both candidate projects after any gate on them + has already decided, and the synchronous arm would then resolve `""` on the event loop -- the + one thing `mark_retire_on_next_claim` documents its callers must prevent. A rollback is a + producer too, so its own arm must follow the revert rather than precede it. + + The resolution runs once both candidate projects are final and + before either is committed, and it is not gated on either being empty — see UNCONDITIONAL + above. It therefore costs one off-thread resolve per switch, and a failure answers + `503 workspace_unavailable` after unwinding the agent commit that precedes it. + + What this writes to DISK, declared because it is a persistence change and not only an + in-memory one. `project` and `project_cleared` are now written unconditionally rather than + only when a project is set, because the ABSENCE of a key cannot distinguish "never scoped" + from "explicitly cleared" — and that distinction is the whole of `claim_cwd`. A record + written by an older build therefore carries neither key, and is read as never-scoped, which + is the safe direction: it states no directory, keeps the warm pool, and cannot resurrect a + clear that build never recorded. The merge is an upsert that cannot delete a key, so a slot + cleared after `/old` was persisted still carries `/old` on disk; `claim_cwd` reads the + cleared marker FIRST for exactly that reason. `resolved_cwd` (`config/paths.py`) is the one + place a falsy directory becomes the concrete per-session default, so no caller has to know + whether a provider was handed `""` or a path. + + What a CLEARED project costs at claim time. `CWD_CLEARED` names the per-session default, + which no pooled child can be sitting in, so `cwd_blocks_pool` routes such a turn to + `pool_decision = "bypass_cwd"` and it cold-starts through the factory. That cost is + intended, not incidental: a warm hit would bind the shared pool directory and so serve + precisely the stale binding this module exists to refuse. The decision is named in the + claim's own telemetry, so the bypass rate is observable rather than silent. + + What bounds a refused clear. A member turn holding the session is not unbounded, which is + why the refusal's "retry when idle" is advice that arrives: every ACP prompt resolves its + wait from `agent.chat_turn_timeout_secs`, clamped to + `[CHAT_TURN_TIMEOUT_MIN, CHAT_TURN_TIMEOUT_MAX]` = 300s..86400s — applied at + `acp/session_handle.py`'s `_effective_prompt_timeout_async` and enforced by the transport + wait on `_turn_done`. So the ceiling is the transport's, not the dashboard's: channel + members are dispatched by `handlers_channel.py`'s `_spawn_agent_task`, a bare + `create_task` that adds no ceiling of its own, so a member turn is bounded by that prompt + timeout alone and by nothing shorter. That is the bound a wedged turn is released by, and + the reason the endpoint refuses rather than forcing: an unconditional clear can only make + the refusal disappear by discarding the state of a turn that is still running. + + What that bound COSTS, recorded because the refusal is a deliberate trade rather than a free + one. The unconditional clear was also the only in-band recovery for a wedged member turn, and + refusing it removes that: the channel route table carries `wake`, `approve`, `update` and + `dismiss` for a member and no stop or interrupt, so the remaining remedies are dismissing the + member — which discards the member, not merely its turn — or waiting out the prompt timeout, + whose default is 14400s. A `force` parameter is deliberately NOT added, because it could only + clear by discarding the state of a live turn, which is the harm the refusal exists to + prevent. OWED FOLLOW-UP — a member-level stop that ends the TURN and leaves the member in + place is the lever this trade is missing; until it exists the prompt timeout is the sole + bound, and that is a choice a maintainer should make knowingly rather than inherit. TRIGGER: + owed before any further surface is given a refusing clear, since each one widens a window the + user has no in-product answer for. + + The queued reset's consume then reads `reset`'s return with that split in mind. `reset` + answers `session is not None`, so a False is AMBIGUOUS: refused-because-busy, or nothing + live to tear down. Only the first is a deferral. The second has already achieved what the + flag asks for, and leaving the flag armed there would have a later consume tear down the + session the eager spawn just created — paying the cold start that path exists to hide — so + `has_session` disambiguates and the flag is cleared. Safety does not rest on that + bookkeeping: the arm is raised BEFORE any branch and keyed on the CURRENT effective key, + so it still covers an in-flight cold start, which is invisible to both probes precisely + because it holds no registry entry yet. Where the session IS registered the flag stays + armed and the bounded retry task owns the follow-up, because a channel-linked slot's turns + cross no dashboard turn boundary and would otherwise never retry the decline. + + Flagging the registered session is not sufficient on its own, because a COLD START has no + registered object to flag. Consider a channel turn that resolves its agent, then the + switch lands, and only then does the turn reach `get_or_create`: its generation snapshot + is taken AFTER the bump so the ordering test reads clean, and a project-scope switch left + the directory alone so the arm's directory test reads clean too — yet the session about to + register runs the agent the switch replaced. Nothing self-corrects, because the next + satisfied claim spends the arm. So an identity arm also records the DESIRED AGENT + (`RetireArm.agent`), and registration is refused when the registering session's agent + is not it. + + Refusing is only half of it: a refusal has to leave the retry able to SATISFY it. The + retry already re-points `cwd` at the arm for exactly this reason, and the agent is + re-pointed the same way — a turn carrying the switched-away agent replayed unchanged is + refused identically every time, so without the re-point the won-race budget runs out and + the slot wedges. That failure is not hypothetical: it is what the first version of this + guard did, caught by `test_a_post_switch_cold_start_cannot_register_the_old_agent`. + + Two constraints on the identity arm follow from that, and both were defects first. + + It must be COMPARABLE to the namespace the REGISTRATION reads, without freezing the + mapping. A claim's agent is `kiro_agent or slot.agent` — an alias's resolved TARGET, + preferred over the alias name — so arming the name the user picked cannot be compared + directly for any alias whose target differs. The consequence is not a missed refusal but an + inverted one: the correctly-resolved cold start is refused, and the retry re-points to the + armed value, running the wrong identity. Arming the RESOLVED target instead fixes the + comparison and introduces the mirror defect: the snapshot outlives the config, so an alias + re-pointed during the arm window leaves the retry re-pointing at the OLD target. So the arm + records the stable ALIAS and `resolve_runtime_agent` maps it to the CURRENT target at every + consume site — the reuse comparison, the cold-start stale check, and the retry rebind. All + THREE must accept both spellings: the retry re-points through the resolver, so a site still + comparing the raw alias can never agree with it, and because the stale branch sets + `retire_on_identity_change` the eviction KEEPS the arm — the retry then re-reads the same + alias until the won-race budget runs out and the claim raises instead of starting. + It resolves only a name the + config DEFINES, because the binding resolver substitutes the default agent for an unknown + one, which would answer a different identity rather than resolve this one; anything else + degrades to the alias, exactly where the claim also degrades to `slot.agent`. The resolve + is OFF-THREAD at every site, and not because it is expensive on average: the config load is + cheap on a cache HIT, but any config edit makes the next one read, parse and schema-validate, + and on the loop that stalls every other session's turn and the heartbeat with it. Each site + resolves BEFORE its critical section — the claim's decision window takes no await, and the + cold-start check runs under the registry lock, where a synchronous read wedges every session + — then uses the pre-resolved value only while the arm still names the alias it resolved. + + And it must be satisfied SEPARATELY from the directory. `cwd_satisfied` is a directory + test, and a project-scope switch keeps the directory, so letting a cwd match spend the + identity arm defeats the mechanism on its own intended consumer path — the claim reuses a + session still running the switched-away agent and the arm is gone. Each arm is answered by + its own predicate, the session is retired unless BOTH are satisfied, and each is spent only + by its own answer. + TWO of the three arm maps are released together; the generation counter is not one of them. + `remove` and + `remove_if_unclaimed` — the session-level eviction paths — PRESERVE the arm deliberately, + because it is still owed to a retry. What reclaims it is the FINAL slot teardown: the close + handler and the archive sweep both call `supersede_arm_for_new_slot` once + `_slot_still_ours` confirms the slot is theirs to end, so a transient key — a channel link, + or a cron, workflow or subagent slot — stops leaving `RetireArm.cwd` and + `RetireArm.agent` entries resident until process exit. It releases nothing a recreate + would have kept, because the mint path is the same verb. `RetireArm.generation` is deliberately + RETAINED and bumped rather than cleared, so a start still in flight from the previous + occupant cannot compare equal to a fresh key's zero: one integer per key the process has + ever armed stays resident by design, which is the price of that guard. + The remaining releases are `spend_retire_arm` (inside `destroy`), `transfer_retire_arm` for a + rebind, and the bulk `discard_all_retire_arms` that `close_all` runs. + On + `False` the helper evicts BEFORE releasing the permit: releasing first would + leave the session registered with a free permit, letting another acquirer win a + provider that the eviction then shuts down mid-command. 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 diff --git a/src/kiro_crew/acp/client.py b/src/kiro_crew/acp/client.py index 483ed5af72c..0672477d306 100644 --- a/src/kiro_crew/acp/client.py +++ b/src/kiro_crew/acp/client.py @@ -3565,9 +3565,9 @@ def __init__( else: # config.paths is a stdlib-only leaf: importing it here can't # re-enter the config.loader -> providers.acp -> acp.client cycle. - from kiro_crew.config.paths import config_dir + from kiro_crew.config.paths import default_workspace_dir - self._work_dir = config_dir() / "workspace" + self._work_dir = default_workspace_dir() # Once-per-instance guard for the ensure_ready work-dir check: True # after the first (off-loop) mkdir, so the per-prompt warm path pays # no filesystem syscall at all. diff --git a/src/kiro_crew/acp/runtime.py b/src/kiro_crew/acp/runtime.py index 7f21dffadcd..bb365e8da15 100644 --- a/src/kiro_crew/acp/runtime.py +++ b/src/kiro_crew/acp/runtime.py @@ -728,9 +728,9 @@ def __init__( else: # config.paths is a stdlib-only leaf: importing it here can't # re-enter the config.loader -> providers.acp -> acp.client cycle. - from kiro_crew.config.paths import config_dir + from kiro_crew.config.paths import default_workspace_dir - self._work_dir = config_dir() / "workspace" + self._work_dir = default_workspace_dir() self._agent = agent # Canonical Kiro Crew agent identity (a cfg.agents key) resolved by the # surface that created this runtime — a DIFFERENT namespace from @@ -3321,6 +3321,7 @@ async def create_session( runtime=self, watchdog=_wd, crew_agent=_crew, + bound_cwd=str(session_work_dir), ) # Populate state from session/new response (configOptions, available models) @@ -3637,6 +3638,7 @@ async def load_session( runtime=self, watchdog=_wd, crew_agent=_crew, + bound_cwd=str(load_params["cwd"]), ) handle.store_session_config(resp) # session/load echoes ``currentModelId`` exactly like session/new, and a diff --git a/src/kiro_crew/acp/session_handle.py b/src/kiro_crew/acp/session_handle.py index a33eb898705..e35fbb09da3 100644 --- a/src/kiro_crew/acp/session_handle.py +++ b/src/kiro_crew/acp/session_handle.py @@ -557,10 +557,14 @@ def __init__( runtime: AcpRuntimeProtocol, watchdog: WatchdogSettings | None = None, crew_agent: str = "", + bound_cwd: str = "", ) -> None: self._session_id = session_id self._queue = queue self._runtime = runtime + # The directory THIS session was opened against, which on a shared runtime is + # not the runtime's own: sessions for different projects live on one process. + self._bound_cwd = bound_cwd # When True, destroy() skips the transcript unlink (subagent # continuability: the transcript is spawn_continue's resume material). self.keep_transcript = False diff --git a/src/kiro_crew/acp/session_provider.py b/src/kiro_crew/acp/session_provider.py index 1cf72716655..bb72e29e2cc 100644 --- a/src/kiro_crew/acp/session_provider.py +++ b/src/kiro_crew/acp/session_provider.py @@ -699,6 +699,21 @@ def _work_dir(self) -> Path: """Working directory (AcpClient-compatible attribute).""" return self._runtime._work_dir + @property + def cwd(self) -> str: + """The directory THIS session is bound to, not the runtime's. + + Overrides the ``LLMProvider`` default ("") so reuse validation reads the real + path through the public capability rather than probing a private attribute. + Reads it off the HANDLE: a shared runtime carries sessions opened against + different projects, so answering with the runtime's own directory would report a + workspace this session never bound, and reuse validation would evict a live + session -- losing its conversation -- for failing to be somewhere it never was. + Falls back to the runtime for a handle predating the record, which is the + single-session case where the two agree anyway. + """ + return str(getattr(self._handle, "_bound_cwd", "") or self._work_dir) + @property def _permission_mode(self) -> str: """Permission mode — always empty for kiro (no CC permission modes).""" diff --git a/src/kiro_crew/apps/builtins/spec_builder/backend/runtime.py b/src/kiro_crew/apps/builtins/spec_builder/backend/runtime.py index 2f99510558c..97a3c3e460a 100644 --- a/src/kiro_crew/apps/builtins/spec_builder/backend/runtime.py +++ b/src/kiro_crew/apps/builtins/spec_builder/backend/runtime.py @@ -650,12 +650,15 @@ async def _ensure_worker_slot( return None try: slot._app = APP_NAME - # cwd for the worker's CLI process (chat_runner: cwd=slot.project). + # cwd for the worker's CLI process (chat_runner: cwd=slot.claim_cwd). # Without it the agent must `cd ` before every command, which # turns every tool pill in the chat into identical cd-noise -- and for a # discovered spec it would edit files outside the project entirely. if safe_wd is not None: slot.project = str(safe_wd) + # The MARKER too: `claim_cwd` reads it before the project, so a slot cleared + # earlier would hand the turn the default workspace and misplace every write. + slot.project_cleared = False # '' = inherit: the session layer's resolution chain applies unchanged. # A concrete pick rides slot.model, which chat_runner already resolves # first — and if the pick stops being served, its withhold keeps the pin diff --git a/src/kiro_crew/channel.py b/src/kiro_crew/channel.py index ff63fb760be..8968d7498cd 100644 --- a/src/kiro_crew/channel.py +++ b/src/kiro_crew/channel.py @@ -319,6 +319,9 @@ class Channel: _save_fn: Any = None # set by ChannelManager _max_agents: int = _MAX_AGENTS max_exchanges: int = _MAX_A2A_EXCHANGES + # Serializes an append against a clear-all: the clear awaits member teardown before + # wiping, and an append in that window is acknowledged then persisted away. + _log_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) def add_agent( self, @@ -401,113 +404,120 @@ async def post( mentions = {mention} mentions.discard(from_id) # no self-mentions - # Resolve reply_to from thread parent - reply_to: str | None = None - if thread_id: - parent = self._msg_index.get(thread_id) - if parent: - reply_to = parent.from_id - parent.reply_count += 1 - - msg = ChannelMessage( - id=uuid.uuid4().hex[:8], - from_id=from_id, - from_role=from_role or from_id, - content=content, - mention=list(mentions) if mentions else None, - msg_type=msg_type, - thread_id=thread_id, - reply_to=reply_to, - ) - self.messages.append(msg) - self._msg_index[msg.id] = msg - if len(self.messages) > _MAX_MESSAGES: - removed = self.messages.pop(0) - self._msg_index.pop(removed.id, None) - - # Human message resets A2A exchange budget — agents get fresh rounds - if from_id == "human": - self.exchange_counts.clear() - - for agent in self.members.values(): - if agent.id == from_id or agent.state in ("done", "failed"): - continue - if agent.listen_mode == ListenMode.SILENT: - continue + # Resolution must share the append's lock: a clear-all wipes `_msg_index` at its + # own turn under that lock, so a parent read outside it can be gone by the append. + async with self._log_lock: + reply_to: str | None = None + if thread_id: + parent = self._msg_index.get(thread_id) + if parent: + reply_to = parent.from_id + parent.reply_count += 1 + else: + thread_id = None - is_human = from_id == "human" + msg = ChannelMessage( + id=uuid.uuid4().hex[:8], + from_id=from_id, + from_role=from_role or from_id, + content=content, + mention=list(mentions) if mentions else None, + msg_type=msg_type, + thread_id=thread_id, + reply_to=reply_to, + ) + self.messages.append(msg) + self._msg_index[msg.id] = msg + if len(self.messages) > _MAX_MESSAGES: + removed = self.messages.pop(0) + self._msg_index.pop(removed.id, None) + + # Human message resets A2A exchange budget — agents get fresh rounds + if from_id == "human": + self.exchange_counts.clear() + + # Delivery and persistence stay under the lock: releasing here let a clear + # wipe and persist between the append and this, delivering an unlogged message. + for agent in self.members.values(): + if agent.id == from_id or agent.state in ("done", "failed"): + continue + if agent.listen_mode == ListenMode.SILENT: + continue - # Thread routing: default listener = parent sender - if thread_id and reply_to == agent.id and not mentions: - await agent.inbox.put(msg) - continue + is_human = from_id == "human" - # Thread fallback: if reply_to doesn't match any agent (e.g. system message), - # route human thread replies to orchestrator - if ( - thread_id - and is_human - and not mentions - and agent.is_orchestrator - and reply_to not in self.members - ): - await agent.inbox.put(msg) - continue + # Thread routing: default listener = parent sender + if thread_id and reply_to == agent.id and not mentions: + await agent.inbox.put(msg) + continue - # Orchestrator gets all top-level human messages (no @mention needed) - if is_human and not mentions and not thread_id and agent.is_orchestrator: - await agent.inbox.put(msg) - continue + # Thread fallback: if reply_to doesn't match any agent (e.g. system message), + # route human thread replies to orchestrator + if ( + thread_id + and is_human + and not mentions + and agent.is_orchestrator + and reply_to not in self.members + ): + await agent.inbox.put(msg) + continue - # Everyone else: strict @mention only - if agent.id not in mentions: - continue + # Orchestrator gets all top-level human messages (no @mention needed) + if is_human and not mentions and not thread_id and agent.is_orchestrator: + await agent.inbox.put(msg) + continue - # A2A exchange limit - if not is_human: - pair = (from_id, agent.id) - if self.exchange_counts.get(pair, 0) >= self.max_exchanges: - logger.info( - "A2A limit reached: %s → %s in channel %s", - from_id, - agent.id, - self.id, - ) + # Everyone else: strict @mention only + if agent.id not in mentions: continue - self.exchange_counts[pair] = self.exchange_counts.get(pair, 0) + 1 - - await agent.inbox.put(msg) - - # Dead agent bounce - for mid in mentions: - target = self.members.get(mid) - if target and target.state in ("done", "failed"): - bounce = ChannelMessage( - id=uuid.uuid4().hex[:8], - from_id="system", - from_role="System", - mention=None, - msg_type="system", - content=f"⚠️ @{target.role} is no longer active.", - ) - self.messages.append(bounce) - self._msg_index[bounce.id] = bounce - if len(self.messages) > _MAX_MESSAGES: - removed = self.messages.pop(0) - self._msg_index.pop(removed.id, None) - self._broadcast( - "channel_message", {"channel_id": self.id, "message": bounce.to_dict()} - ) - # Always broadcast to frontend - self._broadcast( - "channel_message", - { - "channel_id": self.id, - "message": msg.to_dict(), - }, - ) - self._save() + # A2A exchange limit + if not is_human: + pair = (from_id, agent.id) + if self.exchange_counts.get(pair, 0) >= self.max_exchanges: + logger.info( + "A2A limit reached: %s → %s in channel %s", + from_id, + agent.id, + self.id, + ) + continue + self.exchange_counts[pair] = self.exchange_counts.get(pair, 0) + 1 + + await agent.inbox.put(msg) + + # Dead agent bounce + for mid in mentions: + target = self.members.get(mid) + if target and target.state in ("done", "failed"): + bounce = ChannelMessage( + id=uuid.uuid4().hex[:8], + from_id="system", + from_role="System", + mention=None, + msg_type="system", + content=f"⚠️ @{target.role} is no longer active.", + ) + self.messages.append(bounce) + self._msg_index[bounce.id] = bounce + if len(self.messages) > _MAX_MESSAGES: + removed = self.messages.pop(0) + self._msg_index.pop(removed.id, None) + self._broadcast( + "channel_message", + {"channel_id": self.id, "message": bounce.to_dict()}, + ) + + # Always broadcast to frontend + self._broadcast( + "channel_message", + { + "channel_id": self.id, + "message": msg.to_dict(), + }, + ) + self._save() return msg async def subscribe(self, agent_id: str): @@ -767,6 +777,9 @@ async def run_channel_agent( agent=agent.agent_name or None, approval_policy=agent.approval_policy.value, ) + # This lease is released only when the member dies, so a busy probe reading the + # lease would refuse a clear on this channel for the member's whole life. + sessions.mark_lifecycle_lease(agent.session_key) agent.state = "listening" channel._broadcast( @@ -796,6 +809,9 @@ async def run_channel_agent( async for msg in channel.subscribe(agent.id): agent.state = "working" + # Declared BEFORE the setup below, which runs while the provider still reports no + # active turn -- a clear arriving in that window would tear this session down. + sessions.set_lifecycle_turn_active(agent.session_key, True) channel._broadcast( "channel_agent_status", {"channel_id": channel.id, "agent_id": agent.id, "state": "working"}, @@ -823,6 +839,22 @@ async def run_channel_agent( ) orch_toplevel = agent.is_orchestrator and (is_toplevel_human or is_agent_report_back) tid = None if orch_toplevel else (msg.thread_id or msg.id) + if sessions.get_provider(agent.session_key) is not client: + # IDENTITY, not presence: a clear-context discard pops this key and shuts the + # cached provider down, and a later claim can re-register a DIFFERENT one under it. + replacement = await _reacquire_cleared_session(sessions, agent) + if replacement is None: + await channel.post( + agent.id, + "❌ This agent's session could not be re-acquired after its context " + "was cleared. Wake it to try again.", + from_role=agent.role, + msg_type="system", + thread_id=tid, + ) + agent.state = "failed" + break + client = replacement busy = await _stream_task( agent, channel, client, prompt, thread_id=tid, is_yolo=is_yolo ) @@ -853,6 +885,7 @@ async def run_channel_agent( client = replacement agent.state = "listening" + sessions.set_lifecycle_turn_active(agent.session_key, False) channel._broadcast( "channel_agent_status", { @@ -866,6 +899,9 @@ async def run_channel_agent( logger.exception("Channel agent %s (%s) failed", agent.id, agent.role) agent.state = "failed" finally: + # Backstop: a turn left declared would refuse every later clear on this key, which is + # the permanent refusal this change exists to remove. + sessions.set_lifecycle_turn_active(agent.session_key, False) if agent.state not in ("done", "failed"): agent.state = "done" channel._broadcast( @@ -876,6 +912,47 @@ async def run_channel_agent( logger.info("Channel agent %s (%s) finished: %s", agent.id, agent.role, agent.state) +def has_queued_work(agent: ChannelAgent) -> bool: + """Whether *agent* holds an acknowledged message it has not begun yet. + + A post is acknowledged to its sender and persisted once it reaches the inbox, but the + turn is only declared when the member dequeues it. Between the two the session reports + no active turn while work that WILL run is already owed, so a teardown that consults the + turn alone tears down a member that then answers into whatever replaced the log. + + Determined positively: an inbox whose depth is unreadable states nothing, so it resolves + to no queued work and the turn remains the question, rather than refusing every clear. + """ + depth = getattr(getattr(agent, "inbox", None), "qsize", lambda: None)() + return isinstance(depth, int) and depth > 0 + + +async def _reacquire_cleared_session(sessions: Any, agent: ChannelAgent) -> Any: + """Take a fresh lease after this member's session was discarded from under it. + + A clear-context discard pops the registry entry and shuts the provider down, and the + provider this member cached at spawn is that same object -- so without this the member + streams a dead one for every later message and only a restart recovers it. No reset is + owed first, unlike :func:`_reset_busy_session`: the key is already cold, and the single + ``release`` in the listening lifecycle resolves the key at call time, so it balances + against the replacement. + """ + try: + client, _is_new, _resumed = await sessions.get_or_create( + agent.session_key, + agent=agent.agent_name or None, + approval_policy=agent.approval_policy.value, + ) + except Exception: + logger.exception("Failed to re-acquire session %s after a clear", agent.session_key) + return None + sessions.mark_lifecycle_lease(agent.session_key) + # Both markers, not just the lease: this runs mid-turn, and the fresh session defaults to + # no turn -- so a clear in the setup that follows would tear it down unprotected. + sessions.set_lifecycle_turn_active(agent.session_key, True) + return client + + async def _reset_busy_session(sessions: Any, agent: ChannelAgent) -> Any | None: """Replace *agent*'s wedged session and return a lease on a cold one. @@ -910,6 +987,12 @@ async def _reset_busy_session(sessions: Any, agent: ChannelAgent) -> Any | None: except Exception: logger.exception("Failed to re-acquire session %s after reset", agent.session_key) return None + # The listening loop holds THIS lease for the rest of its life too, so it carries the + # same marker as the original: unmarked, a recovered member refuses a clear forever. + sessions.mark_lifecycle_lease(agent.session_key) + # And the turn: the replay below runs on this session, so it needs the same protection + # the original had before the wedge. + sessions.set_lifecycle_turn_active(agent.session_key, True) return client diff --git a/src/kiro_crew/cli_server.py b/src/kiro_crew/cli_server.py index 86038b5eb99..2847d844179 100644 --- a/src/kiro_crew/cli_server.py +++ b/src/kiro_crew/cli_server.py @@ -26,11 +26,11 @@ from kiro_crew.beacon import distribution, is_default_home from kiro_crew.config import KiroCrewConfig from kiro_crew.config.loader import ( - _session_work_dir, build_provider_factory, config_dir, config_path, read_local_secret, + session_default_cwd, ) from kiro_crew.constants import DATA_WARNING from kiro_crew.context import ContextBuilder @@ -2327,7 +2327,7 @@ async def _cli_notify(title: str, body: str, task_id: str = "") -> None: context_builder=ctx, auto_test=auto_test, on_notify=_cli_notify, - work_dir=_session_work_dir("taskrunner:main"), + work_dir=session_default_cwd("taskrunner:main"), conversation_log=conv_log, consolidator=consolidator, lesson_store=lessons, diff --git a/src/kiro_crew/config/loader.py b/src/kiro_crew/config/loader.py index 45f7bc00896..dd85813215a 100644 --- a/src/kiro_crew/config/loader.py +++ b/src/kiro_crew/config/loader.py @@ -522,8 +522,14 @@ def workspace_root() -> Path: return _resolve_workspace_root(base / _WORKSPACE_DIR_NAME) -def _session_work_dir(session_key: str | None) -> Path: - """Return a per-session subdirectory under workspace_root().""" +def session_default_cwd(session_key: str | None) -> Path: + """The directory a provider for *session_key* binds when given no ``cwd``. + + The provider factory binds this, and ``resolved_cwd`` has to answer the SAME directory + or a cleared project compares against one no provider binds -- so both go through this + one symbol rather than agreeing by convention, the same reason + :func:`default_workspace_dir` is one. + """ root = workspace_root() if session_key: return root / _safe_dir_name(session_key) @@ -5120,7 +5126,7 @@ def _acp( crew_agent: str | None = None, **_kwargs: object, ) -> AcpProvider: - wdir = Path(cwd) if cwd else _session_work_dir(session_key) + wdir = Path(cwd) if cwd else session_default_cwd(session_key) # Canonical crew identity for the session (keys per-agent watchdog # windows on the handle) — one shared resolution rule, see # resolve_crew_identity. diff --git a/src/kiro_crew/config/paths.py b/src/kiro_crew/config/paths.py index 2bc2ede69f4..54e3713b650 100644 --- a/src/kiro_crew/config/paths.py +++ b/src/kiro_crew/config/paths.py @@ -327,6 +327,77 @@ def config_dir() -> Path: return d +CWD_CLEARED = "" +"""The ``cwd`` a caller states to say the project was CLEARED, not left unspecified. + +``cwd`` carries two distinguishable answers and one of them is easy to write by +accident. ``None`` means the caller has no requirement, so a stored or inherited +directory may still be restored over it. ``CWD_CLEARED`` is a requirement: the user +removed the project, and the claim must bind the default workspace rather than the +directory the session previously had. A bare ``""`` at a call site reads as the absence +of a value, which is the one thing it does not mean -- so the requirement is named. +""" + + +def default_workspace_dir() -> Path: + """The directory a provider binds to when it is given no working directory. + + Both providers that can be started with no cwd -- the ACP runtime and its client -- fall + back HERE, and session allocation compares a claim naming no directory against what such + a provider then reports. Expressed as a copy of ``config_dir() / "workspace"`` per call + site, that agreement holds only while every copy stays textually in sync: let one drift + and a project-less claim never matches its own binding, which either cold-starts every + turn or exhausts the claim retry budget and wedges the slot. + + Scoped deliberately to those two: the cli, deploy, metrics and spec-builder paths resolve + the same directory for their own reasons and take no part in that comparison, so they keep + spelling it themselves rather than being swept in behind a fix that does not need them. + ``test_no_provider_invents_its_own_no_cwd_fallback`` is what keeps the two in step. + """ + return config_dir() / "workspace" + + +def resolved_cwd(cwd: str, session_key: str | None = None) -> str: + """Resolve a directory the way a provider resolves its own ``work_dir``. + + A provider handed a falsy ``work_dir`` binds to a default, so ``provider.cwd`` + reports that concrete path and never the empty string, and it reports it in the + platform's own spelling because it went through ``Path``. Comparing a raw stored + value against it is therefore two bugs waiting: a cleared project's ``""`` never + matches, and a directory differing only by separator or a trailing slash reads as a + DIFFERENT directory. + + ``session_key`` is what makes the empty case right, and it is REQUIRED there. The + provider FACTORY resolves an empty ``cwd`` to a PER-SESSION directory + (``workspace_root()/``), not to the shared :func:`default_workspace_dir` -- and + those are different roots, so answering the shared one for a cleared project compares a + claim against a directory no provider ever binds. Two harms follow: the claim never + matches its own binding, so the slot cold-starts every turn or exhausts its retry + budget; and any caller that BINDS this answer collapses sessions that are meant to be + isolated onto one directory, where relative writes overwrite each other. So an empty + ``cwd`` with no key raises rather than guessing a root. + + So every comparison of a stated directory against a live ``provider.cwd`` puts both + sides through here -- allocation's claim gate and the switch handlers' "does the + live session already serve the target" checks alike. One definition rather than a + copy per call site, for the same reason :func:`default_workspace_dir` is one. + + :raises ValueError: if ``cwd`` is empty and no ``session_key`` is given. + """ + if cwd: + return str(Path(cwd)) + if not session_key: + raise ValueError( + "resolved_cwd() needs a session_key to resolve an empty cwd: the per-session " + "default is the only directory a provider binds for a cleared project" + ) + # Deferred: this module is a LEAF by design (see the module docstring) and + # ``config.loader`` imports it at module load, so a top-level import cycles. + from kiro_crew.config.loader import session_default_cwd + + return str(session_default_cwd(session_key)) + + def data_home() -> Path: """The resolved data home, WITHOUT re-running start-of-process maintenance. diff --git a/src/kiro_crew/dashboard/channel_slots.py b/src/kiro_crew/dashboard/channel_slots.py index c0a5762724a..a6c2488d587 100644 --- a/src/kiro_crew/dashboard/channel_slots.py +++ b/src/kiro_crew/dashboard/channel_slots.py @@ -456,6 +456,10 @@ def surface_channel_session( slot.memory_store = str(meta["memory_store"]) if meta.get("project"): slot.project = meta["project"] + # Surfacing a channel slot must carry the clear forward too, or the slot looks + # never-scoped and its next claim resumes whatever directory it last had. + if meta.get("project_cleared") is True: + slot.project_cleared = True if meta.get("channel_folder_filed"): slot._channel_folder_filed = True # Persisted tags are applied on EVERY surface, not just first filing: the diff --git a/src/kiro_crew/dashboard/chat_fork.py b/src/kiro_crew/dashboard/chat_fork.py index e7341a10a5c..e6523dcc4ff 100644 --- a/src/kiro_crew/dashboard/chat_fork.py +++ b/src/kiro_crew/dashboard/chat_fork.py @@ -965,6 +965,9 @@ def _source_identity_unchanged() -> bool: # context (agent resolution, steering files, CWD) instead of falling back to # the config/workspace default on first message. new_slot.project = slot.project + # WITH the project: an empty project means two things, and only this marker separates + # "cleared" from "never had one" -- dropped, the fork rebinds what the parent cleared. + new_slot.project_cleared = slot.project_cleared # Inherit the sidebar folder so the fork appears next to its parent in the UI. new_slot.folder_id = slot.folder_id # Inherit tags (copied, so later edits to either slot's list stay independent). diff --git a/src/kiro_crew/dashboard/chat_handlers.py b/src/kiro_crew/dashboard/chat_handlers.py index 49203677720..e112e06c9af 100644 --- a/src/kiro_crew/dashboard/chat_handlers.py +++ b/src/kiro_crew/dashboard/chat_handlers.py @@ -38,6 +38,7 @@ published_autocompact_pct, resolve_agent_bindings, ) +from kiro_crew.config.paths import CWD_CLEARED, resolved_cwd from kiro_crew.dashboard import remote_mirror from kiro_crew.dashboard.channel_slots import channel_slot_name, note_slot_closed from kiro_crew.dashboard.chat_auto_tag import maybe_auto_tag @@ -69,6 +70,8 @@ from kiro_crew.dashboard.chat_runner import ( _context_usage_payload, _run_chat, + _settle_and_transfer_arm, + _settle_arm_target, _start_next_queued_turn, _sync_served_model, context_entry_expired, @@ -1274,6 +1277,20 @@ async def api_chat_slot_source_links(request: web.Request) -> web.Response: ) +def _arm_cwd_for_claim(slot: Any, cleared_arm_cwd: str) -> str | None: + """The directory this slot's arm should state, or ``None`` to state none. + + An UNSET project is not a clear -- neither on the switch that commits one nor on the + rollback that restores one. Its claim states no directory precisely so the warm pool and + the stored-cwd resume override still apply. Arming the cleared fallback there would bind + the per-session default instead, and the next turn's relative writes would land outside + the directory it was resuming. Keyed on ``claim_cwd`` so the arm states exactly what the + claim will, rather than a second reading of the same two fields. + """ + restored = slot.claim_cwd + return cleared_arm_cwd if restored == CWD_CLEARED else restored + + def _finite_number(value: Any) -> float | None: """Return *value* as a float when it is a real, finite number, else None. @@ -2861,6 +2878,10 @@ def _read_folder_tags(folders: list[dict[str, Any]]) -> list[str]: status=409, ) slot.memory_store = assigned_store + # Either assignment above re-scopes a slot whose project was cleared, so the marker it + # carried across the reopen is now stale and would bind the fallback instead. + if slot.project: + slot.project_cleared = False _sync_dashboard_slots(state) # Persist INSIDE the suspension, ahead of the coalesced broadcast, the # same ordering `session_control.py`'s create span uses ("the whole @@ -5389,6 +5410,10 @@ async def _close_slot( # it if the key is no longer ours. if _slot_still_ours(state, name, slot): await state.sessions.remove(_history_key_for(name)) + # Re-checked AFTER the await: a same-key recreate lands in exactly that window and + # arms its own project, and reaping on the pre-await verdict erases the REPLACEMENT's. + if _slot_still_ours(state, name, slot): + state.sessions.supersede_arm_for_new_slot(_history_key_for(name)) _sync_dashboard_slots(state) state.push_slots_update() state.push_refresh("history") @@ -5710,6 +5735,10 @@ async def api_chat_slots_cleanup(request: web.Request) -> web.Response: await state.sessions.remove(_history_key_for(name)) except Exception: logger.warning("Cleanup: session remove failed for %s", name, exc_info=True) + # Outside the try so a failed session teardown still reclaims, but re-checked after + # the await: a same-key recreate in that window owns the key and its own arm now. + if _slot_still_ours(state, name, removed): + state.sessions.supersede_arm_for_new_slot(_history_key_for(name)) archived.append(name) # Collect running tasks for concurrent cancellation after the loop if removed.running and removed.task is not None: @@ -6028,6 +6057,11 @@ async def api_chat_slot_agent(request: web.Request) -> web.Response: }, status=409, ) + + def _authorize_rebind(target_key: str) -> web.Response | None: + """Re-run this handler's own gate against the key an arm transfer would land on.""" + return _app_cancel_denied(request, slot, "chat.slot_agent", target_key) + # Never reset under an in-flight turn (the model handler's policy, # and the _cancel_target subtlety): a RUNNING turn owns a captured # identity because ``linked_session_key`` is mutable, so the key @@ -6063,6 +6097,10 @@ async def api_chat_slot_agent(request: web.Request) -> web.Response: pre_await_workspace = slot.workspace pre_await_project = slot.project pre_await_memory_store = slot.memory_store + pre_await_project_cleared = getattr(slot, "project_cleared", False) + # Captured for the same reason as the other three: a turn starting inside the awaits + # below selects its own agent, which an unconditional commit would erase. + pre_await_agent = slot.agent # Commit the agent BEFORE any await in this section: a message send # landing while the resolution warm-up or the reset await is in @@ -6081,19 +6119,24 @@ async def api_chat_slot_agent(request: web.Request) -> web.Response: # and THAT path does roll back (see the except below), because the # probe has proven the opposite premise: the old session survives on # this old binding. - slot.agent = _CommitToken(agent_name) - # Ownership token for the rollback paths below: the committed value is - # a str SUBCLASS instance whose identity only this request holds — it - # compares, hashes, serializes and persists exactly like the plain - # string, but `slot.agent is ` proves no other writer has - # touched the field since this commit. Any concurrent write — the - # unlocked openai_compat / members / in-turn directive writers - # included, and a SAME-VALUE write especially — replaces the object, - # so the rollback stands down. A value compare-and-set cannot tell - # "still my write" from "their equal write", and rolling back over a - # concurrent same-agent dispatch would restore the old agent under a - # turn already running the new one. - committed_agent = slot.agent + # BEFORE the commit, so an unavailable root answers 503 with nothing published to + # unwind -- an unwind here re-ran this same failed resolve and raised a 500. + try: + cleared_arm_cwd = await state.sessions.resolve_arm_cwd(session_key, CWD_CLEARED) + except Exception: + logger.warning( + "Failed to resolve the default workspace for slot %s", name, exc_info=True + ) + return web.json_response( + { + "error": "the configured workspace directory is unavailable", + "code": "workspace_unavailable", + }, + status=503, + ) + + # NOT published here: everything below awaits, and a cwd-less claim in that + # window would see the new agent with no arm raised and reuse the old session. # Resolve workspace from agent bindings. The response value is seeded # from the slot's CURRENT workspace, not a "default" literal: if @@ -6104,8 +6147,17 @@ async def api_chat_slot_agent(request: web.Request) -> web.Response: # which is exactly when the optimistic write is load-bearing). workspace = slot.workspace or "default" assignment_resolved = False + # Initialised outside the try: on a resolution failure the arm falls back to the + # requested alias, so both sides degrade to the same stable identity. + default_alias: str = "" + # Bound BEFORE the try: the rollback closure below reads it, and a load failure + # here would otherwise leave it unbound for that reader. + cfg: KiroCrewConfig | None = None try: cfg = KiroCrewConfig.load() + # Populated for BOTH arms: a NAMED switch whose rollback restores an empty prior + # agent runs the configured default too, and "" there armed the built-in. + default_alias = cfg.default_agent or "" if agent_name: # Resolve by the name being STORED, which is exactly the name dispatch # will resolve later (`chat_runner` -> resolve_agent_bindings( @@ -6243,12 +6295,72 @@ async def api_chat_slot_agent(request: web.Request) -> web.Response: committed_workspace: str | None = None committed_project: str | None = None committed_memory_store: str | None = None + # Settled and AUTHORIZED before anything is published: a triple visible across the + # resolve's await lets a turn on the rebound key run an agent that may still roll back. + pre_commit_denied, settled_key, settled_arm_cwd, arm_settled = await _settle_arm_target( + state, slot, session_key, new_project, _authorize_rebind + ) + if pre_commit_denied is not None: + sel().log_api_access( + caller=request.get("user", "dashboard"), + operation="chat_slot_agent", + outcome="denied", + resources=f"slot={name} agent={agent_name}", + error="slot rebound to a session this caller may not repoint", + ) + return pre_commit_denied + if not arm_settled: + # Commit and arm are one unit: a key that never settled gets no arm, so a commit + # here publishes a project on the live key with nothing raised to protect it. + return web.json_response( + { + "error": "slot session was rebound during the switch", + "code": "session_rebound", + }, + status=409, + ) + # The agent joins the other two so the binding TRIPLE and the arm below commit + # with no await between them. Identity token, so the rollback can prove ownership. + if slot.agent != pre_await_agent: + # A turn started inside the awaits and picked its own agent; committing would erase + # it and the rollback would erase it again, so this refuses as the siblings' CAS does. + return web.json_response( + { + "error": "agent selection changed during the switch", + "code": "turn_in_flight", + }, + status=409, + ) + slot.agent = _CommitToken(agent_name) + committed_agent = slot.agent if slot.workspace == pre_await_workspace: slot.workspace = _CommitToken(new_workspace) committed_workspace = slot.workspace if slot.project == pre_await_project: slot.project = _CommitToken(new_project) committed_project = slot.project + # An unset project is not a clear: marking an unscoped slot cleared would cost it + # its resume and its warm-pool hit, on a slot the user never cleared. + slot.project_cleared = not new_project and bool( + pre_await_project or pre_await_project_cleared + ) + + # Raised at the PRODUCER, synchronously: the consumer runs behind the eager + # task's debounce, and a channel turn in that window states no cwd. + # The ALIAS, not the resolved target: a config re-point during the arm window + # would otherwise feed the retry a frozen agent. Resolution happens at consume. + armed_agent = agent_name or default_alias or "kirocrew" + state.sessions.mark_retire_on_next_claim( + session_key, + _arm_cwd_for_claim(slot, cleared_arm_cwd), + agent=armed_agent, + ) + # Onto the key settled BEFORE the commit, synchronously: no claim may read the published + # triple while the arm still names the key the slot left. An unsettled key returned above. + if settled_key != session_key: + state.sessions.transfer_retire_arm( + session_key, settled_key, _arm_cwd_for_claim(slot, settled_arm_cwd) + ) # The store is the THIRD field of that binding, and leaving it behind # splits the slot in half: the turn resolves its store fresh from the new # agent's bindings while the consolidator writes to the store recorded at @@ -6265,7 +6377,7 @@ async def api_chat_slot_agent(request: web.Request) -> web.Response: "Slot %s agent switched to %r, resetting session", name, agent_name or "kirocrew" ) - def _rollback_switch() -> None: + async def _rollback_switch() -> None: """Unwind this request's commit — only the values still OURS. EVERY field is unwound on IDENTITY of its commit token, never @@ -6279,12 +6391,14 @@ def _rollback_switch() -> None: this commit's; a field this request never committed (the write-side CAS lost) has a None token and is never touched. """ - if slot.agent is committed_agent: + restored_agent = slot.agent is committed_agent + if restored_agent: slot.agent = prior_agent if committed_workspace is not None and slot.workspace is committed_workspace: slot.workspace = pre_await_workspace if committed_project is not None and slot.project is committed_project: slot.project = pre_await_project + slot.project_cleared = pre_await_project_cleared if committed_memory_store is not None and slot.memory_store is committed_memory_store: slot.memory_store = pre_await_memory_store # Re-mark unconditionally: the periodic flush writes a slot's @@ -6292,13 +6406,46 @@ def _rollback_switch() -> None: # rollback that follows a persisted provisional binding leaves # the rejected values on disk across a restart. slot._dirty = True + # Re-point the arm raised before the awaits, on the binding the slot holds + # AFTER the unwind -- see docs/system-specs/modules/session.md. The ALIAS the + # slot ends on, never its resolved target: resolution belongs at consume. + # `default_alias` before the literal, as the commit-side arm above: an EMPTY + # restored agent runs the CONFIGURED default, not the built-in name. + state.sessions.mark_retire_on_next_claim( + session_key, + _arm_cwd_for_claim(slot, cleared_arm_cwd), + agent=(prior_agent if restored_agent else slot.agent) + or default_alias + or "kirocrew", + ) + # `session_key` was read before the awaits and `linked_session_key` is assigned + # outside `slot._lock`, so a rebind since then left this arm on a dead key. + await _settle_and_transfer_arm( + state, slot, session_key, slot.claim_cwd, _authorize_rebind + ) + + # A rebind can still land after the commit, so the target is re-checked here too; the + # pre-commit gate above is what keeps an unauthorized agent from ever being published. + rebind_denied, _, _ = await _settle_and_transfer_arm( + state, slot, session_key, slot.claim_cwd, _authorize_rebind + ) + if rebind_denied is not None: + await _rollback_switch() + sel().log_api_access( + caller=request.get("user", "dashboard"), + operation="chat_slot_agent", + outcome="denied", + resources=f"slot={name} agent={agent_name}", + error="slot rebound to a session this caller may not repoint", + ) + return rebind_denied if ( state._slots.get(slot.key) is not slot or effective_session_key(slot) != session_key or slot.agent is not committed_agent ): - _rollback_switch() + await _rollback_switch() return web.json_response( {"error": "slot changed during agent resolution", "code": "session_rebound"}, status=409, @@ -6313,7 +6460,7 @@ def _rollback_switch() -> None: # below are what keep the teardown off a streaming turn. recheck = state.sessions.get_provider(session_key) if slot.running or (isinstance(recheck, LLMProvider) and recheck.has_active_turn()): - _rollback_switch() + await _rollback_switch() return web.json_response( {"error": "a turn is in flight", "code": "turn_in_flight"}, status=409 ) @@ -6322,7 +6469,7 @@ def _rollback_switch() -> None: # still has children must refuse rather than discard their work. children_409 = _subagents_attached_response(state, slot, session_key, "slot_agent") if children_409 is not None: - _rollback_switch() + await _rollback_switch() return children_409 teardown_incomplete = False reset_ok = True @@ -6354,7 +6501,7 @@ def _rollback_switch() -> None: # during the raising await keeps its win) and re-push so clients # and persisted state land on the rolled-back truth, then let the # raise escape as a 500. - _rollback_switch() + await _rollback_switch() state.push_slots_update() raise if reset_verdict is None: @@ -6372,7 +6519,7 @@ def _rollback_switch() -> None: busy_provider = state.sessions.get_provider(session_key) if isinstance(busy_provider, LLMProvider): if busy_provider.has_active_turn(): - _rollback_switch() + await _rollback_switch() return web.json_response( {"error": "a turn is in flight", "code": "turn_in_flight"}, status=409 ) @@ -6387,7 +6534,7 @@ def _rollback_switch() -> None: state, slot, session_key, switch_kind="agent" ) except Exception: - _rollback_switch() + await _rollback_switch() state.push_slots_update() raise if reset_verdict is None: @@ -6399,7 +6546,7 @@ def _rollback_switch() -> None: and not teardown_incomplete and state.sessions.get_provider(session_key) is not None ): - _rollback_switch() + await _rollback_switch() return web.json_response( {"error": "a turn is in flight", "code": "turn_in_flight"}, status=409 ) @@ -6415,7 +6562,7 @@ def _rollback_switch() -> None: # model and workspace handlers use. Checked BEFORE the metadata # write below so a rolled-back agent is never persisted for # restart. - _rollback_switch() + await _rollback_switch() return web.json_response( {"error": "slot session was rebound during the switch", "code": "session_rebound"}, status=409, @@ -6455,7 +6602,7 @@ def _rollback_switch() -> None: # writer took ownership during the awaits, its value is the # truthful current one. The metadata is transcript-scoped and # binding-independent, so its restore needs no further re-check. - _rollback_switch() + await _rollback_switch() if state.conversation_log: try: await asyncio.to_thread( @@ -8097,6 +8244,11 @@ async def api_chat_slot_workspace(request: web.Request) -> web.Response: denied = _app_cancel_denied(request, slot, "chat.slot_workspace", session_key) if denied is not None: return denied + + def _authorize_rebind(target_key: str) -> web.Response | None: + """Re-run this handler's own gate against the key an arm transfer would land on.""" + return _app_cancel_denied(request, slot, "chat.slot_workspace", target_key) + # A started conversation is NOT refused. Such a refusal protects # nothing the sibling handlers protect: the transcript and its # session key are workspace-independent (the name is a metadata @@ -8146,15 +8298,74 @@ async def api_chat_slot_workspace(request: web.Request) -> web.Response: ) prior_workspace = slot.workspace prior_project = slot.project + prior_project_cleared = getattr(slot, "project_cleared", False) + new_project = default_project_dir(ws_name) + # The resolve runs FIRST: it is the step that can raise on an unavailable workspace + # root, and recording ahead of it left the rejected project armed behind the 503. + try: + cleared_arm_cwd = await state.sessions.resolve_arm_cwd(session_key, CWD_CLEARED) + except Exception: + logger.warning("Failed to record the workspace change for slot %s", name, exc_info=True) + return web.json_response( + { + "error": "the configured workspace directory is unavailable", + "code": "workspace_unavailable", + }, + status=503, + ) + + # Resolve the arm target BEFORE publishing: this awaits, and a claim landing there + # would follow an arm naming a project the CAS below may keep another value over. + # Resolved for the CLEARED state, not this request's candidate: a clear winning the CAS + # would otherwise be answered with an arm naming the workspace it rejected. + rebind_denied, settled_key, settled_cleared_cwd, arm_settled = await _settle_arm_target( + state, slot, session_key, "", _authorize_rebind + ) + if rebind_denied is not None: + sel().log_api_access( + caller=request.get("user", "dashboard"), + operation="chat_slot_workspace", + outcome="denied", + resources=f"slot={name} workspace={ws_name}", + error="slot rebound to a session this caller may not repoint", + ) + return rebind_denied + if not arm_settled: + # Commit and arm are one unit: an unsettled key gets no arm, so a commit here + # publishes bindings with nothing raised to protect them. + return web.json_response( + { + "error": "slot session was rebound during the switch", + "code": "session_rebound", + }, + status=409, + ) # Commit as identity tokens (the agent handler's _CommitToken # precedent): ``slot.project`` has lock-free writers -- the in-turn # set_project directive lands during the reset await -- so a rollback # must unwind only the value THIS request wrote, never a concurrent # write of a different (or even the same) text. committed_workspace = _CommitToken(ws_name) - committed_project = _CommitToken(default_project_dir(ws_name)) slot.workspace = committed_workspace - slot.project = committed_project + # Compare-and-set for the project: the awaits above give an unlocked writer a window + # to land in, and overwriting it here would lose a project the user just picked. + committed_project: str | None = None + if slot.project == prior_project: + slot.project = _CommitToken(new_project) + committed_project = slot.project + # `default_project_dir` answers "" for a missing or sensitive root, and an empty + # project without this flag reads as never-set, so the resume restores the old one. + slot.project_cleared = not new_project + # Publish and transfer on the project that ACTUALLY won the CAS, with no suspension + # since the commit: a resolved cwd records synchronously, so no claim reads a half pair. + # An EMPTY project arms the per-session default, which differs per key, so it must be + # the one resolved for the key the transfer lands on -- not the abandoned key's. + armed_cwd = slot.project or settled_cleared_cwd + await state.sessions.note_project_change(session_key, armed_cwd) + # An unsettled key means the slot rebound after the last resolve, so this would arm + # a session nobody is on; the helper already retracted the source arm. + if arm_settled: + state.sessions.transfer_retire_arm(session_key, settled_key, armed_cwd) logger.info("Slot %s workspace switched to %r, resetting session", name, ws_name) def _rollback() -> None: @@ -8170,8 +8381,9 @@ def _rollback() -> None: """ if slot.workspace is committed_workspace: slot.workspace = prior_workspace - if slot.project is committed_project: + if committed_project is not None and slot.project is committed_project: slot.project = prior_project + slot.project_cleared = prior_project_cleared slot._dirty = True # skip_if_busy: message dispatch does not take slot._lock, so a send @@ -8214,7 +8426,12 @@ def _rollback() -> None: # rolling back would advertise the old workspace while the # live process runs the new one. Success without teardown is # the truthful answer. - live_serves_target = busy_provider.cwd == slot.project + # provider.cwd is REPORTED, so Path-normalized; slot.project is raw. + # An EMPTY project resolves the per-session default, which mkdirs and realpaths + # the workspace root -- synchronous work this reuses off-thread instead. + live_serves_target = resolved_cwd(busy_provider.cwd, session_key) == resolved_cwd( + slot.project or cleared_arm_cwd, session_key + ) if live_serves_target: logger.info( "Slot %s workspace switch: live session already runs under %r; " @@ -8228,6 +8445,14 @@ def _rollback() -> None: # pair is already visible) and answer the same 409 the # guard gives. _rollback() + # The switch is REJECTED: an arm still naming the rejected project would + # send the next claim there, so re-point it at what we rolled back to. + await state.sessions.note_project_change( + session_key, _arm_cwd_for_claim(slot, cleared_arm_cwd) + ) + await _settle_and_transfer_arm( + state, slot, session_key, slot.claim_cwd, _authorize_rebind + ) return web.json_response( {"error": "a turn is in flight", "code": "turn_in_flight"}, status=409 ) @@ -8245,6 +8470,14 @@ def _rollback() -> None: teardown_incomplete = True elif not reset_ok: _rollback() + # The switch is REJECTED: an arm still naming the rejected project would + # send the next claim there, so re-point it at what we rolled back to. + await state.sessions.note_project_change( + session_key, _arm_cwd_for_claim(slot, cleared_arm_cwd) + ) + await _settle_and_transfer_arm( + state, slot, session_key, slot.claim_cwd, _authorize_rebind + ) return web.json_response( {"error": "a turn is in flight", "code": "turn_in_flight"}, status=409 ) @@ -8252,11 +8485,19 @@ def _rollback() -> None: # message cold-starts under the new bindings. if effective_session_key(slot) != session_key: # The slot was bound to a different session during the reset - # await(s): the session torn down is no longer the slot's, so the - # committed bindings would describe a session that never saw the + # await(s): the session torn down is not the one the slot names, so + # the committed bindings would describe a session that never saw the # switch. Roll back and answer 409; the retry resolves the # current binding. _rollback() + # The switch is REJECTED: an arm still naming the rejected project would + # send the next claim there, so re-point it at what we rolled back to. + await state.sessions.note_project_change( + session_key, _arm_cwd_for_claim(slot, cleared_arm_cwd) + ) + await _settle_and_transfer_arm( + state, slot, session_key, slot.claim_cwd, _authorize_rebind + ) return web.json_response( {"error": "slot session was rebound during the switch", "code": "session_rebound"}, status=409, @@ -8279,7 +8520,15 @@ def _rollback() -> None: async def api_chat_slot_project(request: web.Request) -> web.Response: - """POST /api/chat/slots/{slot}/project — set project directory for file search scoping.""" + """POST /api/chat/slots/{slot}/project — set project directory for file search scoping. + + Authorization is checked against the key the slot SETTLES on, not only the key the request + arrived under. A slot can rebind while this request awaits, so ``_settle_arm_target`` re-runs + the same ``_app_cancel_denied`` gate on the settled key and the request is denied when that + gate refuses. Arming a key this caller may not repoint is therefore impossible, which is the + property that permits a rebind to a key it MAY repoint to proceed rather than be refused as + collateral: the gate decides, not the timing of the rebind. + """ state: DashboardState = request.app["state"] name = request.match_info["slot"] slot = state._slots.get(name) @@ -8368,14 +8617,59 @@ async def api_chat_slot_project(request: web.Request) -> web.Response: if denied is not None: return denied old_project = slot.project - # _CommitToken (identity-gated rollback), the agent handler's pattern: - # slot.project has unlocked writers (the in-turn set_project directive - # writes this field without the lock, and may legitimately write the - # very project this handler sets). A value compare-and-set rollback - # cannot tell such a same-text write from this handler's own commit and - # would erase it; a per-request identity token can. + old_project_cleared = getattr(slot, "project_cleared", False) + changing = project != old_project + # Resolved BEFORE anything is committed. This is the only fallible step here, and + # a commit ahead of it leaves the slot on the new project with no arm raised. + cleared_arm_cwd = "" + if changing: + try: + cleared_arm_cwd = await state.sessions.resolve_arm_cwd( + session_key, project or CWD_CLEARED + ) + except Exception: + logger.warning( + "Failed to resolve the default workspace for slot %s", name, exc_info=True + ) + return web.json_response( + { + "error": "the default workspace could not be resolved", + "code": "workspace_unavailable", + }, + status=503, + ) + if effective_session_key(slot) != session_key: + # Rebound during the resolve. Nothing is committed yet, so there is no + # rollback to make; answer the same 409 the sibling switch handlers use. + return web.json_response( + { + "error": "slot session was rebound during the switch", + "code": "session_rebound", + }, + status=409, + ) + if slot.project != old_project: + # An in-turn `set_project` directive landed inside the resolve, writing without + # this lock, so committing here would erase a change made after this began. + return web.json_response( + { + "error": "the slot's project changed during the switch", + "code": "turn_in_flight", + }, + status=409, + ) + # Commit and arm with NO await between them: a cwd-less claim in such a window + # reads the new project with no arm raised and reuses the old session. committed_project = _CommitToken(project) slot.project = committed_project + # An EMPTY project is a deliberate clear only where there was scope to clear; on a slot + # that never had one it would drop the resume SID and the warm-pool hit for nothing. + slot.project_cleared = not project and bool(old_project or old_project_cleared) + if changing: + slot._pending_reset_history_key = session_key + armed_generation = state.sessions.mark_retire_on_next_claim( + session_key, project or cleared_arm_cwd + ) logger.info("Slot %s project set to %r", name, project) sel().log_api_access( caller=request.get("user", "dashboard"), @@ -8383,43 +8677,59 @@ async def api_chat_slot_project(request: web.Request) -> web.Response: outcome="allowed", resources=f"slot={name} project={project}", ) - # Track recent projects + # Persisted AFTER the arm: this is disk I/O and must not sit inside the window. if project: try: await asyncio.to_thread(_save_recent_project, project) except Exception: logger.warning("Failed to save recent project", exc_info=True) - # Reset the session so the next message cold-starts with the new CWD and - # picks up project-level .kiro/steering/**/*.md (mirrors api_chat_slot_agent). - # Only on an actual change — avoids a needless cold start on a no-op set. - # - # Deferred via a flag because this endpoint is reachable over loopback HTTP - # from inside the kiro-cli process group (the set_project MCP tool); an - # inline reset would killpg() the caller. Consumed in chat_runner. - if project != old_project: + if changing: + # The save above awaits, so the slot can rebind: the arm and pending key + # would name a session nobody is on. Transfer both, as the consumer does. if effective_session_key(slot) != session_key: - # The slot was bound to a different session while the - # recent-project save awaited: arming the flag with the key - # this request resolved would have the consumer tear down a - # session nobody is on while the slot's ACTUAL session keeps - # the old CWD — the exact stale-binding class this handler - # was converted to remove. Re-resolving here instead is not - # an option either: it would arm a key the app gate above - # never authorized. Roll back the commit (identity-gated on - # the _CommitToken — the in-turn set_project directive writes - # this field without the lock, and a same-value write must not - # be mistaken for this handler's own commit) and answer the - # same 409 the sibling switch handlers use. - if slot.project is committed_project: - slot.project = old_project - return web.json_response( - { - "error": "slot session was rebound during the switch", - "code": "session_rebound", - }, - status=409, + # Settled, not snapshot-then-resolve: the cleared-cwd resolve awaits, so a key + # read before it can be abandoned by the time the transfer lands. + rebind_denied, settled_key, settled_cwd, arm_settled = await _settle_arm_target( + state, + slot, + session_key, + slot.project or "", + lambda key: _app_cancel_denied(request, slot, "chat.slot_project", key), + only_generation=armed_generation, ) - slot._pending_reset_history_key = session_key + if rebind_denied is not None: + # Unwind rather than arm an unauthorized key, and RETRACT the deferred + # reset: the consumer would settle it onto the very key the gate refused. + if slot.project is committed_project: + slot.project = old_project + slot.project_cleared = old_project_cleared + slot._pending_reset_history_key = None + sel().log_api_access( + caller=request.get("user", "dashboard"), + operation="chat_slot_project", + outcome="denied", + resources=f"slot={name} project={project}", + error="slot rebound to a session this caller may not repoint", + ) + return rebind_denied + if arm_settled: + state.sessions.transfer_retire_arm(session_key, settled_key, settled_cwd) + slot._pending_reset_history_key = settled_key + else: + # An unsettled key gets no arm, so a published project would stand with + # nothing raised to protect it: unwind, exactly as the denial above does. + if slot.project is committed_project: + slot.project = old_project + slot.project_cleared = old_project_cleared + slot._pending_reset_history_key = None + slot._dirty = True + return web.json_response( + { + "error": "slot session was rebound during the switch", + "code": "session_rebound", + }, + status=409, + ) # Speculatively re-create the session rooted at the new project so the # cwd change is paid during think-time. The eager task consumes the # deferred reset itself, but only when no turn is running — the @@ -9403,6 +9713,10 @@ async def api_chat_slot_resume(request: web.Request) -> web.Response: slot.workspace = meta["workspace"] if meta.get("project"): slot.project = meta["project"] + # Resuming from History must carry the clear forward, or the slot reads as never-scoped + # and its next claim binds the directory the clear was meant to drop. + if meta.get("project_cleared") is True: + slot.project_cleared = True if meta.get("channel_folder_filed"): # Resuming from History must carry the filing marker forward, or the # next save of this slot drops it and the conversation is re-filed. diff --git a/src/kiro_crew/dashboard/chat_orchestrator.py b/src/kiro_crew/dashboard/chat_orchestrator.py index c5fd9c49ea3..28319dbf44a 100644 --- a/src/kiro_crew/dashboard/chat_orchestrator.py +++ b/src/kiro_crew/dashboard/chat_orchestrator.py @@ -351,7 +351,7 @@ async def _exit_cancelled_plan(state: "DashboardState", slot: "_ChatSlot") -> No slot._queue[:] = [e for e in slot._queue if not _is_plan_approval_entry(e)] if ( not slot.running - and not slot._last_turn_auth_required + and not slot._queue_held and state._slots.get(slot.key) is slot and slot._queue and not slot._stopping @@ -1093,9 +1093,20 @@ async def _stage_loop( # skips the handoff entirely (queue preserved for the torn-down slot). # ``state._slots.get(...) is slot`` guards a slot DELETED mid-plan (slot.task # is None between stages, so deletion isn't blocked): never launch a turn on - # a slot that is no longer registered. ``not slot._last_turn_auth_required`` - # mirrors _run_chat's own guard: a signed-out CLI holds the queue for - # post-login resume instead of popping it into another auth failure. + # a slot absent from the registry. ``not slot._queue_held`` + # mirrors _run_chat's own guard: when a turn has proved every QUEUED prompt + # would fail identically, those prompts must not be popped into repeated + # failures. Deliberately not phrased as "the turn ran nothing" -- that holds + # for a signed-out CLI or a reset refused BEFORE the turn, but the third + # cause is a reset left deferred at the END of a turn that completed + # normally. What the flag asserts is about the QUEUE, not this turn. It + # asks only whether the flag is set, so a further cause needs no + # edit here. The + # project-reset path REACHES this finally by design -- _run_chat re-raises + # under a plan so an unexecuted stage is never credited -- so this is where + # the hold has to be honoured. Without it, the re-raise that correctly stops + # the plan would drain the held prompts HERE, one failure card each, which is + # the data loss the hold exists to prevent. # ``not slot.running`` defers entirely to a turn a stage's _run_chat may # have already started (e.g. a refusal-recovery continuation): that live # task owns slot.task and will drain the queue + emit chat_done itself, so @@ -1145,7 +1156,7 @@ async def _stage_loop( if ( not _cancelled and not _turn_live - and not slot._last_turn_auth_required + and not slot._queue_held and state._slots.get(slot.key) is slot and slot._queue and not slot._stopping diff --git a/src/kiro_crew/dashboard/chat_persistence.py b/src/kiro_crew/dashboard/chat_persistence.py index 1e328aaf891..4902f4ce0f2 100644 --- a/src/kiro_crew/dashboard/chat_persistence.py +++ b/src/kiro_crew/dashboard/chat_persistence.py @@ -32,6 +32,7 @@ _normalize_model, _redact_meta_for_role, _sync_dashboard_slots, + bind_linked_session_key, effective_session_key, slot_history_key, slot_transcript_key, @@ -1065,6 +1066,8 @@ def _rehydrate_slot_from_history( slot.memory_store = str(meta["memory_store"]) if meta.get("project"): slot.project = meta["project"] + if meta.get("project_cleared") is True: + slot.project_cleared = True # Restore the remote executor marker INDEPENDENTLY of its target fields. # history JSONL is a file on disk, so a truncated write or a hand-edit can # leave the ``executor="remote"`` marker without a valid instance_id / @@ -1172,7 +1175,7 @@ def _rehydrate_slot_from_history( # Rebind the slot to the session its conversation actually runs on. # Skipped, the slot would answer from a dashboard-only session and the # channel thread would stop seeing its replies. - slot.linked_session_key = str(meta["linked_session_key"]) + bind_linked_session_key(slot, str(meta["linked_session_key"])) # Re-seed the live compaction threshold. The SessionManager's override # map is process-local, so a rehydrated slot must push its persisted # value back or the session silently compacts at the global threshold. @@ -1635,6 +1638,8 @@ def _apply_recent_session( slot.memory_store = str(meta["memory_store"]) if meta.get("project"): slot.project = meta["project"] + if meta.get("project_cleared") is True: + slot.project_cleared = True if _member_identity is None and (_mode := _restored_mode(meta.get("mode"))): slot.mode = _mode if meta.get("created_by"): @@ -1712,7 +1717,7 @@ def _apply_recent_session( if meta.get("forked_from") is not None: slot.forked_from = meta["forked_from"] if meta.get("linked_session_key"): - slot.linked_session_key = str(meta["linked_session_key"]) + bind_linked_session_key(slot, str(meta["linked_session_key"])) elif is_channel_session_key(key) and state.sessions: # First time this thread is surfaced: bind it to the session the # channel itself runs. Resolved from the session map, never derived @@ -1720,7 +1725,7 @@ def _apply_recent_session( # a guess could point the tab at a session the channel never reads. real_key = state.sessions.channel_key_for_stem(key) if real_key: - slot.linked_session_key = real_key + bind_linked_session_key(slot, real_key) # Re-seed the live compaction threshold (see _rehydrate_slot_from_history). if slot.autocompact_pct is not None and state.sessions: state.sessions.set_autocompact_pct(effective_session_key(slot), slot.autocompact_pct) @@ -2999,8 +3004,10 @@ def _fresh_fields() -> dict: # falsy as "the global store", which is also how a session written # before crew stores existed reads. fields["memory_store"] = named_store_or_empty(slot.memory_store) - if slot.project: - fields["project"] = slot.project + # Both written even when EMPTY/False: the merge is an upsert that cannot delete + # a key, so either one retained would outlive the change that replaced it. + fields["project"] = slot.project + fields["project_cleared"] = bool(getattr(slot, "project_cleared", False)) if slot._app: fields["app"] = slot._app if slot._origin: @@ -3312,6 +3319,9 @@ def _refresh_under_lock(meta: dict) -> bool: meta_line["memory_store"] = _named if slot.project: meta_line["project"] = slot.project + # Unconditional, matching the merge above: a reader that reconciles this line against + # a retained key must see a False rather than an absence it can interpret either way. + meta_line["project_cleared"] = bool(getattr(slot, "project_cleared", False)) # Remote-execution binding. All three are written together or not at # all: a half-restored binding (executor="remote" with no peer slot) # is the fail-closed refusal case, so persisting the marker without diff --git a/src/kiro_crew/dashboard/chat_runner.py b/src/kiro_crew/dashboard/chat_runner.py index c6e4bfa261c..809a28057b7 100644 --- a/src/kiro_crew/dashboard/chat_runner.py +++ b/src/kiro_crew/dashboard/chat_runner.py @@ -54,6 +54,7 @@ resolve_agent_bindings, resolve_effective_model, ) +from kiro_crew.config.paths import CWD_CLEARED from kiro_crew.connections import get_visible_providers from kiro_crew.constants import strip_control_comments from kiro_crew.context import prepare_store_vectors @@ -111,9 +112,11 @@ expire_slack_options, is_harness_slash_command, is_system_injection_item, + key_rebind_deferred, mirror_is_paused, parse_workflow_command, remember_slack_options, + settling_key, slack_mirror_is_paused, user_text_span, ) @@ -739,6 +742,108 @@ async def _credential_tool_hint_for(reason: str, cause: str, subject: str = "") _STEER_NOTICE_BOUND_SECS = 5.0 +def _retract_own_arm(state: Any, from_key: str, only_generation: int | None) -> None: + """Retract *from_key*'s arm only where this caller is the producer that armed it. + + An unscoped retract spends whichever arm is resident, so a caller that armed nothing + erased a project arm another producer owned -- and the next claim stating no directory, + which is every channel turn, was then served the superseded project's session with + nothing left to retire it. A caller holding no generation therefore leaves the arm + where it is: it stays owed to a claim on this key, which is the outcome the arm exists + to force, and slot mint or final teardown reclaims it. + """ + if only_generation is None: + return + state.sessions.supersede_arm_for_new_slot(from_key, only_generation=only_generation) + + +async def _settle_arm_target( + state: Any, + slot: Any, + from_key: str, + project: str | None, + authorize: Callable[[str], Any] | None = None, + *, + only_generation: int | None = None, +) -> Any: + """Resolve WHICH key an arm should land on, publishing nothing. + + The awaits live here so a caller that must publish its arm and finalize its binding can + do both with no suspension between: a claim landing inside this resolve would otherwise + follow an arm naming a project a later compare-and-set may not keep. + + The effective key can move while a cleared cwd resolves off-thread, and a cwd resolved + for the losing key names a directory no winner's provider binds -- arming there evicts + the live session, whose won-race retry then lands in a scratch dir. Each pass therefore + re-resolves for whichever key wins, and EVERY resolve is verified, including the last, + so a rebind landing after the final pass is detected rather than armed onto the key the + slot just left. The bound is the pass count, and exhausting it is reported: an unsettled + key publishes no arm here either. + + `authorize` gates the key an arm would land on, because the caller's own gate cleared + `from_key` alone and a rebind can move the slot onto a session it has no claim on. On + denial the source arm is retracted, scoped to the caller's own generation, and the caller + must publish nothing. A caller with no external principal -- the turn loop, whose reset + was authorized when armed -- passes none. + + Returns the denial (or ``None``), the key that won, the cwd resolved for it, and + whether the key SETTLED -- a caller must publish nothing when it did not. + """ + with settling_key(slot): + current_key = effective_session_key(slot) + # `== CWD_CLEARED`, never truthiness: an UNSET project is empty too, and resolving the + # cleared default for it would arm a directory the stored-cwd resume must override. + armed_cwd = ( + await state.sessions.resolve_arm_cwd(current_key, CWD_CLEARED) + if project == CWD_CLEARED + else project + ) + # ONE resolve: every writer of the linked key routes through `bind_linked_session_key`, + # which PARKS while this region is open, so the key cannot move under the await. + settled = effective_session_key(slot) == current_key + if settled and key_rebind_deferred(slot): + # A rebind reached the slot inside the region and lands as it unwinds, so the + # key just settled on is already spent. + settled = False + if not settled: + _retract_own_arm(state, from_key, only_generation) + return None, current_key, armed_cwd, False + if authorize is not None and current_key != from_key: + denied = authorize(current_key) + if denied is not None: + _retract_own_arm(state, from_key, only_generation) + return denied, current_key, armed_cwd, True + return None, current_key, armed_cwd, True + + +async def _settle_and_transfer_arm( + state: Any, + slot: Any, + from_key: str, + project: str | None, + authorize: Callable[[str], Any] | None = None, +) -> Any: + """Re-point *from_key*'s arm onto the key the slot runs on now. + + For a caller whose binding is already final, so the transfer can follow the resolve + directly. One that still has to commit calls :func:`_settle_arm_target` and transfers + itself, keeping its publish and its commit in one no-suspension window. + + Returns the denial (or ``None``), the key the slot runs on, and whether the transfer + SETTLED. An unsettled resolve reports the key it last observed, which can be the one it + started from, so key equality alone does not tell a caller its arm landed. + """ + with settling_key(slot): + denied, current_key, armed_cwd, settled = await _settle_arm_target( + state, slot, from_key, project, authorize + ) + if denied is not None: + return denied, current_key, settled + if settled: + state.sessions.transfer_retire_arm(from_key, current_key, armed_cwd) + return None, current_key, settled + + async def _steer_policy_notice( client: Any, title: str, @@ -4093,6 +4198,45 @@ async def _retry() -> None: _pending_reset_retries[slot.key] = (slot, asyncio.create_task(_retry())) +# Whether a turn ends with the queue HELD rather than drained, stored on +# ``slot._queue_held``. Deliberately a BOOLEAN and not a reason string: every +# cause means the same thing to every drain gate -- this turn proved every queued +# prompt would fail identically -- and no gate, log line or test ever asked WHICH. +# The causes today are a signed-out CLI, a queued project change refused before the +# turn, and one left deferred after it; a further cause composes by setting this +# flag, and a further drain site by asking this one question. + + +def _app_owned_rebind_denied(slot: Any) -> Callable[[str], Any] | None: + """The authorize gate for a DEFERRED reset, which carries no request to re-check. + + Every other caller of the settle helpers passes `_app_cancel_denied`, re-running the app + check against the key the transfer lands on. This path armed its flag inside a turn and + consumes it later, so it has no request -- and the assumption that "the reset was + authorized when armed" does not survive a rebind: a cron/workflow link, or a channel + link, moves the arm onto a session the app has no claim on, and the transfer would then + retire and re-root it unauthenticated. + + The same rule `_app_cancel_denied` applies, read off the slot instead of a request: an + app-owned slot may only act on its OWN dashboard session. A dashboard-owned slot has no + app scope, so it returns None and the settle behaves exactly as before. + """ + owning_app = getattr(slot, "_app", "") + if not owning_app: + return None + # circular import: chat_handlers imports this module at load, so this cannot be top-level. + from kiro_crew.dashboard.chat_handlers import _history_key_for + + own_session = _history_key_for(slot.key) + + def _denied(target_key: str) -> Any: + if target_key == own_session: + return None + return f"app {owning_app} does not own this slot's linked session: {target_key}" + + return _denied + + async def _consume_pending_reset( state: DashboardState, slot: _ChatSlot, *, allow_discard: bool = False ) -> bool: @@ -4151,8 +4295,27 @@ async def _consume_pending_reset( torn_down = False if slot._pending_reset_history_key: pending_key = slot._pending_reset_history_key - current_key = effective_session_key(slot) - if pending_key != current_key: + # Armed regardless of outcome on the key the reset will actually land on, and + # TRANSFERRED so a rebind leaves no arm on the key the slot abandoned. + denied, current_key, arm_settled = await _settle_and_transfer_arm( + state, slot, pending_key, slot.claim_cwd, _app_owned_rebind_denied(slot) + ) + if denied is not None: + # A re-pointed flag names this same settled key, and the gate runs only where the + # settled key DIFFERS from the armed one, so no retry could re-check it. + logger.warning("Dropping deferred reset for slot %s: %s", slot.key, denied) + # AUDITED, not only logged: this is the app-isolation boundary refusing, and an + # operator reconstructing who reached across it reads SEL, not the app log. + sel().log_api_access( + caller=str(getattr(slot, "_app", "") or ""), + operation="chat_deferred_reset", + outcome="denied", + resources=f"slot={slot.key}", + error="app-owned slot rebound to a foreign session", + ) + slot._pending_reset_history_key = None + return torn_down + if pending_key != current_key or not arm_settled: # The slot REBOUND after the flag was armed (a cron/workflow slot # gets linked when its first result is injected; a channel link can # land between arming and this consume). Resetting the stale key and @@ -4163,7 +4326,9 @@ async def _consume_pending_reset( # producer already validated the project change belongs to this # slot, and re-pointing the key needs no re-authorization (it names # the slot's own live session, not a new authority). - slot._pending_reset_history_key = current_key + # An UNSETTLED transfer takes this branch even when the key it reports is the one + # armed: it never landed, and the rebind that stopped it settling moves the slot. + slot._pending_reset_history_key = effective_session_key(slot) _arm_pending_reset_retry(state, slot) return torn_down if subagents_attached(state, slot, pending_key, "consume_pending_reset"): @@ -4185,17 +4350,21 @@ async def _consume_pending_reset( # channel reply. The check and the teardown must be one atomic # step under the session lock. # - # The flag is spent ONLY on a real teardown (reset returned - # True). A False return cannot distinguish "nothing registered + # A False return cannot distinguish "nothing registered # under the key" from a busy decline or a session that is # COLD-STARTING and not yet registered — clearing on a probe # that answered None would let a concurrent cold start carrying # the old CWD register afterwards and serve the stale project - # with the flag already gone. Leaving it armed is always safe: - # the next consume lands it, at worst costing one redundant - # cold start after the reset tears down an already-correct - # idle session. - reset_ok = await state.sessions.reset(pending_key, skip_if_busy=True) + # with the flag already gone. + # A lifetime lease is held for the member's whole life, so refusing on the + # lease alone would defer this user's project change forever. + # HELD ACROSS THE AWAIT: the settle's own region closed before this call, so a + # rebind landing here would reach a session no arm is owed to. + with settling_key(slot): + reset_ok = await state.sessions.reset( + pending_key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + rebound_mid_reset = key_rebind_deferred(slot) except Exception: # A teardown that raised leaves the session in a state this # slot cannot vouch for — neither is its withhold verdict, so @@ -4218,24 +4387,36 @@ async def _consume_pending_reset( # show a withheld model as available. slot.forget_session_model_state() torn_down = True - if slot._pending_reset_history_key == pending_key: + if rebound_mid_reset: + # The key that parked inside the await names a session no arm is owed + # to, so the flag re-points to it instead of clearing. + slot._pending_reset_history_key = effective_session_key(slot) + _arm_pending_reset_retry(state, slot) + elif slot._pending_reset_history_key == pending_key: slot._pending_reset_history_key = None # Freshness push for open tabs; verdict-driven (see # _broadcast_expired_oauth_banners). _broadcast_expired_oauth_banners(state, slot) else: - logger.debug( - "Deferring queued project-change reset for slot %s: " - "no teardown landed (busy, cold-starting, or no session)", - slot.key, - ) - # A channel-linked slot's turns never pass a dashboard - # turn boundary, so a decline here would otherwise never - # be retried and the live channel session would keep the - # old CWD indefinitely. The bounded retry task owns the - # follow-up (deduped; a decline observed by the task - # itself does not stack a second one). - _arm_pending_reset_retry(state, slot) + if not state.sessions.has_session(pending_key): + # Nothing registered: the flag's purpose is already met, and + # leaving it armed would reset the eager spawn's own session. + if slot._pending_reset_history_key == pending_key: + slot._pending_reset_history_key = None + logger.debug( + "Cleared queued project-change reset for slot %s: " + "no session was registered, so nothing was owed", + slot.key, + ) + else: + logger.debug( + "Deferring queued project-change reset for slot %s: " + "session busy, pinned for retirement at next claim", + slot.key, + ) + # A channel-linked slot crosses no dashboard turn boundary, so + # the bounded retry task owns the follow-up. + _arm_pending_reset_retry(state, slot) if allow_discard and slot._pending_discard_conversation_key: discard_key = slot._pending_discard_conversation_key if subagents_attached(state, slot, discard_key, "consume_pending_discard"): @@ -4957,7 +5138,7 @@ async def _spawn_admitted_prefetch( # alias applied, so no override applies. crew_agent=crew_alias, model=slot.model or agent_model or default_model or None, - cwd=slot.project or None, + cwd=slot.claim_cwd, speculative=True, speculative_resume=allow_resume, reasoning_effort_override=slot.reasoning_effort or None, @@ -6232,10 +6413,36 @@ async def _run_pending_synthesis(state: DashboardState, slot: _ChatSlot) -> None def _finish_queue_cycle( state: DashboardState, slot: _ChatSlot, *, allow_automatic_successor: bool = True ) -> None: - """Start synthesis when eligible, otherwise mark a queue cycle idle.""" + """Start synthesis when eligible, otherwise mark a queue cycle idle. + + ``slot._queue_held`` withholds the synthesis dispatch for the same reason the + caller withheld the queue drain, and is read off the slot rather than taken as + a parameter: the only caller that ever set it published it to the slot a few + lines earlier in the same scope, so a parameter was a second spelling of one + fact. Synthesis is not a dead end for the + queue: ``_run_pending_synthesis`` drains it too, calling + ``_start_next_queued_turn`` when ``slot._queue`` is non-empty. So a caller that + held the queue back and then let synthesis start would have the prompts + dequeued behind it, reach the same failure, and lose them — defeating the hold + entirely. Withheld rather than cancelled: ``_pending_synthesis`` is cleared + inside ``_run_pending_synthesis``, so not dispatching leaves the note ARMED for + the next cycle, and this function still finalizes the turn below. + + This CHANGES a pre-existing default, deliberately. Before, only the tail drain + consulted the auth hold and this dispatch did not, so a signed-out CLI held the + queue here and then lost the same prompts through synthesis -- the loss path + above, reached by the older of the two causes. Generalising the gate closes that + rather than introducing a new restriction, so the auth cause is not carved out: + a carve-out would knowingly keep the leak for the cause that predates this + change. Pinned by ``test_a_signed_out_cli_also_holds_the_queue_against_synthesis``, + which drives the real ``AcpAuthRequired`` path, against + ``test_synthesis_still_dispatches_when_nothing_is_held`` as the positive control + that an unheld cycle still synthesises. + """ will_synthesize = ( allow_automatic_successor + and not slot._queue_held and slot._pending_synthesis and not slot._synthesis_inflight # A slot gone from the registry is being torn down, so it has no next @@ -6909,7 +7116,12 @@ def _record_terminal_question(kind: str, outcome: str) -> None: # by the consecutive pre-stream-exhaustion branch in the AcpError handler # below. needs_conversation_discard = False - _auth_required = False + # Whether this turn holds the queue instead of draining it; False drains. + # Several causes can set it, and they are NOT mutually exclusive: the auth wall + # is found in the streaming section, while a deferred reset is found later, in + # the end-of-turn consume. Where both occur the flag stays set -- the + # end-of-turn inference only ever ADDS a hold, it never clears one. + _queue_held = False saw_compaction = False # True once a compaction STARTED notice landed this turn, so the terminal # branch can tell "the backend compacted in the middle of this turn" from @@ -7546,7 +7758,7 @@ def _require_current_binding() -> None: # carry different watchdog windows. crew_agent=crew_alias, model=slot.model or agent_model or default_model or None, - cwd=slot.project or None, + cwd=slot.claim_cwd, reasoning_effort_override=slot.reasoning_effort or None, ) _acquired = True @@ -12531,7 +12743,7 @@ def _emit_error(msg: str, *, will_retry: bool = False) -> None: # Every queued prompt would hit the same wall. Popping them one by one # would drain the whole queue into identical failures, leaving nothing to # resume after the user signs in — so hold the queue intact instead. - _auth_required = True + _queue_held = True needs_session_reset = True if assistant_text: slot.purge_chunks() @@ -13448,6 +13660,9 @@ def _emit_error(msg: str, *, will_retry: bool = False) -> None: schedule_eager_spawn(state, slot) except Exception: logger.debug("_consume_pending_reset failed", exc_info=True) + # OUTSIDE the guard above: a RAISING consume leaves the flag ARMED, and computing + # this inside let that raise drain a queue whose reset had never been applied. + _queue_held = _queue_held or slot._pending_reset_history_key is not None # ── Requeue unconsumed steers ── # A steer handed to kiro-cli that never echoed steering_consumed dies # with the turn (stall-cancel, soft STOP, error, or a steer that raced @@ -13478,26 +13693,44 @@ def _emit_error(msg: str, *, will_retry: bool = False) -> None: slot._wait_state = None slot._end_wait_request = None slot._wait_contested = False - # Record this turn's auth outcome so the orchestrator _stage_loop, which - # runs stages as separate _run_chat calls, can mirror this same - # "hold the queue for post-login resume" guard on its end-of-plan handoff. - slot._last_turn_auth_required = _auth_required + # Publish this turn's hold outcome so every drain gate OUTSIDE this frame + # reads the same answer: the orchestrator's _exit_cancelled_plan and + # _stage_loop finally, which run stages as separate _run_chat calls and + # drain the queue themselves. Set in the `finally` so it is published on + # every exit path, including one that leaves the frame by raising: those + # gates run their own `finally` and drain through + # `_start_next_queued_turn`, so the prompts held just below would be popped + # there instead and burned into repeat failures. Assigned unconditionally so + # it self-clears on the next turn rather than latching. + slot._queue_held = _queue_held next_turn_started = False - if slot._queue and not _auth_required and _memory_preparation_admitted: + if slot._queue and not _queue_held and _memory_preparation_admitted: # After startup admission, the successor's own ACP attempt remains # the authority for a later sign-out. A turn cancelled while waiting # on shared preparation retains the queue instead of walking every # item through the same unfinished or cancelled gateway task. # - # `_auth_required` is the ONE exception: this turn just proved the CLI - # is signed out, so every queued prompt would fail identically. The - # queue is left intact (cards stay visible and individually - # cancellable) and resumes on the user's next send after they log in - # — the no-loss rule, without a readiness waiter to strand it. + # The exception is a HOLD REASON, of which there are currently three -- + # the CLI is signed out, a queued project change was refused before the + # turn, or one was left deferred after it. All three say the same + # thing, which is why this asks only whether a reason is set: this + # turn proved every queued prompt would fail identically, so draining + # would burn the queue into error cards with nothing run. Held, the + # queue stays intact (cards visible and individually cancellable) and + # resumes on the user's next send — after they log in, or once the + # holding turn releases — the no-loss rule, without a readiness waiter + # to strand it. state.push_slots_update() next_turn_started = await _start_next_queued_turn(state, slot) if not next_turn_started: + # Same holds, second drain site. The guard above stops the tail drain, + # but synthesis drains the queue as well (``_run_pending_synthesis`` + # calls ``_start_next_queued_turn`` whenever the queue is non-empty), + # so a hold must suppress it too or the prompts are dequeued there + # instead and burned. The reason is passed straight through rather than + # re-derived here, so this site cannot drift out of agreement with the + # gate above. _finish_queue_cycle( state, slot, diff --git a/src/kiro_crew/dashboard/chat_utils.py b/src/kiro_crew/dashboard/chat_utils.py index e06c909333d..20dab5639c4 100644 --- a/src/kiro_crew/dashboard/chat_utils.py +++ b/src/kiro_crew/dashboard/chat_utils.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import contextlib import hashlib import hmac import json @@ -14,6 +15,7 @@ import re import time import uuid +from collections.abc import Iterator from dataclasses import dataclass from datetime import datetime, timezone from enum import Enum @@ -759,6 +761,61 @@ def effective_session_key(slot: _ChatSlot) -> str: return getattr(slot, "linked_session_key", "") or _history_key_for(slot.key) +@contextlib.contextmanager +def settling_key(slot: _ChatSlot) -> Iterator[None]: + """Exclude a ``linked_session_key`` rebind for the settle-and-arm region. + + An arm names a session key, so a rebind landing between the key a settle resolved + against and the transfer that publishes the arm leaves it owed to a binding nobody is + on. Wrap the whole region -- the settle, the commit, and the transfer -- so the key + cannot move underneath it. + + A SYNCHRONOUS non-blocking guard rather than :attr:`_ChatSlot._lock`, for two + independently disqualifying reasons: seven of the eight writers are synchronous + functions, which cannot acquire an asyncio lock at all, and one transfer already runs + inside an ``async with slot._lock`` block, where a second acquisition of a + non-reentrant lock never returns. Deferring rather than blocking also cannot deadlock. + + A writer arriving inside the region parks its key (:func:`bind_linked_session_key`); + this applies it on the way out, so the rebind is delayed rather than lost. + """ + slot._key_settling += 1 + try: + yield + finally: + slot._key_settling -= 1 + if slot._key_settling == 0 and isinstance(slot._key_deferred, str): + pending, slot._key_deferred = slot._key_deferred, None + slot.linked_session_key = pending + + +def bind_linked_session_key(slot: _ChatSlot, key: str) -> bool: + """Bind *slot* to session *key*, or park the bind while an arm is settling. + + The single writer of ``linked_session_key``. Returns whether the key landed now; a + parked key is applied when the settling region unwinds. + + A depth that is not an ``int`` is not a settling region, so the key lands. Parking is + the direction that LOSES a bind on an object that never unwinds one, so an unrecognised + shape resolves to assigning rather than to deferring. + """ + depth = getattr(slot, "_key_settling", 0) + if isinstance(depth, int) and depth > 0: + slot._key_deferred = key + return False + slot.linked_session_key = key + return True + + +def key_rebind_deferred(slot: _ChatSlot) -> bool: + """Whether a rebind parked inside the current settling region. + + Only a parked KEY counts: anything else is no rebind, so a settle is not refused on an + object that merely carries the attribute. + """ + return isinstance(getattr(slot, "_key_deferred", None), str) + + def subagents_attached( state: DashboardState, slot: _ChatSlot | None, session_key: str, operation: str ) -> bool: diff --git a/src/kiro_crew/dashboard/cron_inject.py b/src/kiro_crew/dashboard/cron_inject.py index 6707f421abb..77144ddbb9b 100644 --- a/src/kiro_crew/dashboard/cron_inject.py +++ b/src/kiro_crew/dashboard/cron_inject.py @@ -10,6 +10,7 @@ import math from typing import TYPE_CHECKING, Any +from kiro_crew.dashboard.chat_utils import bind_linked_session_key from kiro_crew.dashboard.state import DashboardState, SlotOrigin, row_mid from kiro_crew.history import append_rows_if_absent_off_loop from kiro_crew.security import redact_credentials, redact_exfiltration_urls @@ -399,7 +400,7 @@ def _bind_cron_slot( if job.memory_store: slot.memory_store = job.memory_store if not slot.linked_session_key: - slot.linked_session_key = f"cron:{job.id}" + bind_linked_session_key(slot, f"cron:{job.id}") hydrate_slot_from_history(slot, history or []) # Publish the (possibly just-created) tab to the dashboard-surface registry # BEFORE anything routes against it. Every gate that asks "does this session diff --git a/src/kiro_crew/dashboard/handlers/cron.py b/src/kiro_crew/dashboard/handlers/cron.py index 4f849f2c91b..b251f5de8b7 100644 --- a/src/kiro_crew/dashboard/handlers/cron.py +++ b/src/kiro_crew/dashboard/handlers/cron.py @@ -37,6 +37,7 @@ resolve_script_path, validate_secret_env_grant, ) +from kiro_crew.dashboard.chat_utils import bind_linked_session_key from kiro_crew.dashboard.cron_inject import ( hydrate_slot_from_history, inject_cron_result_to_dashboard, @@ -1707,7 +1708,7 @@ async def api_cron_to_chat(request: web.Request) -> web.Response: if history: slot = state.get_or_create_slot(name=slot_name, agent="", origin=SlotOrigin.CRON) if not slot.linked_session_key: - slot.linked_session_key = session_key + bind_linked_session_key(slot, session_key) hydrate_slot_from_history(slot, history) else: # No session log — fall back to notification body. diff --git a/src/kiro_crew/dashboard/handlers/side.py b/src/kiro_crew/dashboard/handlers/side.py index bc05dc9ab8c..5558dcad369 100644 --- a/src/kiro_crew/dashboard/handlers/side.py +++ b/src/kiro_crew/dashboard/handlers/side.py @@ -386,7 +386,7 @@ def _on_steer_consumed(snapshot: str) -> None: # project and the spawn happen in the new one, loading a file the check # never saw. A change is picked up by the NEXT turn, whose binding then # differs and cold-starts under its own derivation. - project: str | None = slot.project or None + project: str | None = slot.claim_cwd slot_agent: str | None = slot.agent or None # The READ_ONLY policy's classifier is the gateway's ONE live hook gate, # the same object the main chat consults (``chat_runner`` reads diff --git a/src/kiro_crew/dashboard/handlers_channel.py b/src/kiro_crew/dashboard/handlers_channel.py index 9a5d37a428b..b68adef9be6 100644 --- a/src/kiro_crew/dashboard/handlers_channel.py +++ b/src/kiro_crew/dashboard/handlers_channel.py @@ -14,6 +14,7 @@ ChannelManager, ListenMode, _shell_base_binary, + has_queued_work, run_channel_agent, ) from kiro_crew.config.loader import config_path @@ -558,6 +559,121 @@ async def api_channel_approve_agent(request: web.Request) -> web.Response: # ── Context Management ── +#: Bound on the WHOLE clear while `_log_lock` is held, since `post` shares that lock. +_CLEAR_DISCARD_TIMEOUT_SECS = 30.0 + +#: How long a cancelled teardown gets to answer. Separate from the clear's own deadline: this +#: one is spent only on the refusal path, and it is what makes a reported refusal true. +_CANCEL_GRACE_SECS = 2.0 + + +async def _note_reset(state, agent, cleared: list, busy: list, deadline: float) -> None: + """Reset one member's session and record it as cleared or refused. + + `discard_conversation` reports False when `skip_if_busy` found the session's semaphore + HELD by an ACTIVE turn. `refuse_only_on_active_turn=True` is what narrows it to that: a + channel member holds its lifecycle lease across its whole listening life, so a lease alone + would refuse every clear on the channel forever, and only a lifecycle turn declared active + refuses. A key with nothing registered takes the teardown path and answers + True, so presence is probed separately and an absent session is NOT reported as cleared. + + A member holding an acknowledged-but-undequeued message is refused ahead of that probe: + its session declares no turn yet, so the discard would answer True and the wipe would + erase a prompt the member still runs. + + A REPORTED REFUSAL MUST BE TRUE, which is why the deadline path cancels rather than + leaving the shielded teardown running. `discard_conversation` pops the session and clears + the SID early, before the slow `provider.shutdown()`, so a member whose wait expires + before its own pop -- reachable when an earlier member has spent the shared deadline -- + would be reported busy while the surviving task then discards the very session the API + said it kept, and the SID is dropped rather than retained, so the next turn cold-starts + with no history. The registry is therefore read AFTER the teardown settles. + """ + label = agent.role or agent.id + if has_queued_work(agent) or agent.state == "working": + # Refused BEFORE the discard, not after: the discard is what acknowledges the clear, + # and this member will run a message the wipe below would already have erased. + # The STATE too, not the queue alone: a dequeued message leaves the queue empty while + # the turn is only declared later, and the wipe in that window is unrecoverable. + busy.append(label) + return + # `reset` keeps the persisted resume SID, so an idle or expired session reloads the + # very conversation this endpoint reports cleared. `discard_conversation` drops it. + # A member holds its lease for its whole listening life, so refusing on the lease would + # refuse this clear for as long as the member exists; the retry it offers reads the turn. + # SHIELDED so the timeout releases `_log_lock` without cancelling a teardown midway: + # `post` shares that lock, so an unbounded shutdown wedges every message in the channel. + # Read BEFORE the discard: a key with nothing registered takes `discard_conversation`'s + # teardown path and answers True, which is indistinguishable from a real clear. + try: + had_session = bool(state.sessions.has_session(agent.session_key)) + except Exception: + # Unreadable registry: assume a session existed, so a real clear is still reported. + had_session = True + _teardown = asyncio.ensure_future( + state.sessions.discard_conversation( + agent.session_key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + ) + try: + discarded = await asyncio.wait_for( + asyncio.shield(_teardown), + timeout=max(0.0, deadline - asyncio.get_running_loop().time()), + ) + except asyncio.TimeoutError: + # The shield keeps the teardown alive past the deadline, so a refusal answered now + # would be contradicted by it -- see this function's docstring. + _teardown.cancel() + try: + await asyncio.wait_for( + asyncio.gather(_teardown, return_exceptions=True), timeout=_CANCEL_GRACE_SECS + ) + settled = True + except asyncio.TimeoutError: + # Uncancellable shutdown. Releasing the lock matters more than waiting it out -- + # `post` is behind it -- so the answer is decided below without it. + settled = False + # Read AFTER it settled: with nothing still running, this is the final state. + try: + still_registered = bool(state.sessions.has_session(agent.session_key)) + except Exception: + # Unreadable registry: refuse rather than claim a clear nothing confirmed. + still_registered = True + if settled and still_registered: + # A TRUE refusal: the cancellation landed before the pop, so nothing was + # discarded and nothing is left running that could discard it. + busy.append(label) + return + if not settled: + # Cannot promise either outcome, so it reports the one that cannot lose a + # conversation: a false refusal hides a context being destroyed. + logger.warning( + "Clear for %s: teardown did not answer cancellation within %.1fs; reporting " + "cleared because a refusal cannot be guaranteed once it may still commit", + agent.session_key, + _CANCEL_GRACE_SECS, + ) + cleared.append(label) + return + # The context IS gone, so reporting a refusal would tell the user their history + # survived while the next turn starts empty. Slowness is logged, not reported as one. + logger.warning( + "Clear for %s: provider shutdown still running when the clear's %.0fs deadline " + "expired; the conversation was already discarded, so it is reported as done", + agent.session_key, + _CLEAR_DISCARD_TIMEOUT_SECS, + ) + cleared.append(label) + return + if discarded: + # Only a session that EXISTED can have been cleared. An absent one had no context, so + # naming it cleared would credit this endpoint with work it did not do. + if had_session: + cleared.append(label) + else: + busy.append(label) + + async def api_channel_clear_context(request: web.Request) -> web.Response: """Clear LLM context for one or all agents in a channel. @@ -568,7 +684,10 @@ async def api_channel_clear_context(request: web.Request) -> web.Response: Scope semantics: * scope=all — resets every agent's LLM session AND wipes the channel's - shared message buffer + exchange counts. Persisted via _save(). + shared message buffer + exchange counts, but ONLY when every + member was idle. A PARTIAL clear leaves the shared buffer + intact, because a busy member keeps the LLM context that + references it. Persisted via _save(). * scope=agent — resets ONLY the named agent's LLM session. The channel's shared message history and exchange counts are preserved, so the cleared agent will still see prior messages on its @@ -623,6 +742,9 @@ async def api_channel_clear_context(request: web.Request) -> web.Response: return web.json_response({"error": "invalid scope"}, status=400) cleared: list[str] = [] + # A clear-context click is USER-COMMANDED, so a refused reset is reported rather than + # swallowed -- declining is right, but pretending it cleared is not. + busy: list[str] = [] if scope == "agent": if not agent_id: @@ -635,27 +757,60 @@ async def api_channel_clear_context(request: web.Request) -> web.Response: ) return web.json_response({"error": "agent_id required"}, status=400) agent = ch.members.get(agent_id) - if not agent: + # The whole clear runs under the channel's log lock: the resets below AWAIT, so a post + # cannot reach the inbox mid-clear and one already there refuses its member. + async with ch._log_lock: + # ONE deadline for the whole clear, not one per member: `post` waits on this lock, so + # an N-member channel with a per-member bound stalls every message for up to N x 30s. + deadline = asyncio.get_running_loop().time() + _CLEAR_DISCARD_TIMEOUT_SECS + if scope == "agent": + if not agent: + sel().log_api_access( + caller="dashboard", + operation="channel.clear_context", + outcome="denied", + source="dashboard", + resources=f"{ch.id}:{agent_id}", + ) + return web.json_response({"error": "agent not found"}, status=404) + if agent.session_key: + await _note_reset(state, agent, cleared, busy, deadline) + else: + for agent in ch.members.values(): + if agent.session_key: + await _note_reset(state, agent, cleared, busy, deadline) + + # BEFORE the buffer wipe below: that is shared state `_save()` persists, so a 409 + # answered after it destroys the log this response reports as untouched. + if busy and not cleared: sel().log_api_access( caller="dashboard", operation="channel.clear_context", outcome="denied", source="dashboard", - resources=f"{ch.id}:{agent_id}", + resources=f"{ch.id}:{scope}:busy={','.join(busy)}", ) - return web.json_response({"error": "agent not found"}, status=404) - if agent.session_key: - await state.sessions.reset(agent.session_key) - cleared.append(agent.role or agent.id) - else: - for agent in ch.members.values(): - if agent.session_key: - await state.sessions.reset(agent.session_key) - cleared.append(agent.role or agent.id) - ch.messages.clear() - ch._msg_index.clear() - ch.exchange_counts.clear() - ch._save() + return web.json_response( + { + "error": ( + "context not cleared: " + + ", ".join(busy) + + " had a turn in flight. Nothing was cleared -- retry when idle." + ), + "code": "turn_in_flight", + "busy": busy, + }, + status=409, + ) + + # Gated on a FULLY clean clear: the log is shared, and a busy member keeps the LLM + # context that references it, so wiping it here would strand that member's replies. + cleared_shared_log = scope != "agent" and not busy + if cleared_shared_log: + ch.messages.clear() + ch._msg_index.clear() + ch.exchange_counts.clear() + ch._save() sel().log_api_access( caller="dashboard", @@ -665,15 +820,21 @@ async def api_channel_clear_context(request: web.Request) -> web.Response: resources=f"{ch.id}:{scope}:{','.join(cleared)}", ) - # Notify other clients (multi-tab UX) so their stale message buffers refresh. - ch._broadcast( - "channel_context_cleared", - { - "channel_id": ch.id, - "scope": scope, - "agent_id": agent_id if scope == "agent" else None, - "cleared": cleared, - }, - ) + # Only when the shared log actually emptied. The listener REPLACES its retained transcript + # with an empty list, so announcing a partial clear wipes the log this request just kept. + if cleared_shared_log: + # Carries what the listener reads and nothing else: the gate above forces `scope` to + # "all", so a per-agent id, the cleared roles and the busy roles are all dead here. + ch._broadcast( + "channel_context_cleared", + { + "channel_id": ch.id, + "scope": scope, + }, + ) - return web.json_response({"ok": True, "cleared": cleared}) + # A partial clear is distinguished by `ok`, not by the status: `busy` alone was read as a + # complete clear by every caller but the SPA, and the status stays 200 per the contract. + if busy: + return web.json_response({"ok": False, "cleared": cleared, "busy": busy}, status=200) + return web.json_response({"ok": True, "cleared": cleared}, status=200) diff --git a/src/kiro_crew/dashboard/session_directive_apply.py b/src/kiro_crew/dashboard/session_directive_apply.py index a1d7768ccc7..3fd58bba105 100644 --- a/src/kiro_crew/dashboard/session_directive_apply.py +++ b/src/kiro_crew/dashboard/session_directive_apply.py @@ -28,8 +28,8 @@ this consumer applies (and may refuse) after the fact. IMPORTS ARE DELIBERATELY FUNCTION-LOCAL here, except for the shared session and -Research ownership contracts plus the immutable ``AUTONUDGE_STOP_REASON`` -constant. ``sel`` is a genuine cycle +Research ownership contracts plus the immutable ``AUTONUDGE_STOP_REASON`` and +``CWD_CLEARED`` constants. ``sel`` is a genuine cycle (``sel`` -> config -> apps -> dashboard, and chat_runner imports this module before it imports sel). The rest (autonudge, autonudge_authz, chat_utils, security, chat_handlers) are deferred on purpose: they keep this module cheap to @@ -56,6 +56,7 @@ MONITOR_TERMINAL_REASON, is_channel_key, ) +from kiro_crew.config.paths import CWD_CLEARED from kiro_crew.messaging.link import is_channel_session_key from kiro_crew.session_surface import has_dashboard_surface @@ -981,9 +982,23 @@ async def _set_project(state: Any, slot: Any, args: dict[str, Any]) -> str: project = str(args.get("project") or "").strip() old_project = getattr(slot, "project", "") or "" if clear or not project: + armed = "" + if old_project: + key = effective_session_key(slot) + # Resolved BEFORE the slot is touched: this is the only fallible step, and + # mutating first would leave the slot cleared but unarmed on failure. + try: + armed = await state.sessions.resolve_arm_cwd(key, CWD_CLEARED) + except Exception: + logger.debug("resolve of the cleared workspace failed", exc_info=True) + return "Error: the default workspace could not be resolved." slot.project = "" + # Gated like the reset and arm below: a slot that never had a project has nothing to + # clear, and marking it drops its resume SID and its warm-pool hit for nothing. + slot.project_cleared = bool(old_project or getattr(slot, "project_cleared", False)) if old_project: - slot._pending_reset_history_key = effective_session_key(slot) + slot._pending_reset_history_key = key + state.sessions.mark_retire_on_next_claim(key, armed) _push(state) return "Project cleared. The next message cold-starts with no project scope." expanded = os.path.expanduser(project) @@ -1022,8 +1037,13 @@ def _validate() -> tuple[str, bool, bool]: if overlap is not None: return f"Error: {overlap}" slot.project = rp + slot.project_cleared = False if rp != old_project: - slot._pending_reset_history_key = effective_session_key(slot) + # Armed HERE, not left to the consumer: a claim carrying no cwd in the window before + # the deferred reset would otherwise be served the session bound to `old_project`. + key = effective_session_key(slot) + slot._pending_reset_history_key = key + state.sessions.mark_retire_on_next_claim(key, rp) try: from kiro_crew.dashboard.chat_handlers import _save_recent_project diff --git a/src/kiro_crew/dashboard/state.py b/src/kiro_crew/dashboard/state.py index f2366fe032d..e712667abef 100644 --- a/src/kiro_crew/dashboard/state.py +++ b/src/kiro_crew/dashboard/state.py @@ -32,6 +32,7 @@ config_dir, resolve_effective_agent, ) +from kiro_crew.config.paths import CWD_CLEARED from kiro_crew.constants import ( OPTIONS_RE_LINE, SUBAGENT_BATCH_COMPLETION_PREFIX, @@ -3463,6 +3464,7 @@ class _ChatSlot: "memory_store", "_memory_assignment_from_history", "project", + "project_cleared", "created_at", "messages", "total_messages", @@ -3517,7 +3519,7 @@ class _ChatSlot: "_plan_cancelled", "_auto_run", "_in_stage_execution", - "_last_turn_auth_required", + "_queue_held", "_recovery_chat_triggered", "_stage_titles", "_stage_descriptions", @@ -3580,6 +3582,8 @@ class _ChatSlot: "_origin", "_pending_variants", "_lock", + "_key_settling", + "_key_deferred", "forked_from", "_fork_lock", "_model_pick_lock", @@ -3668,6 +3672,9 @@ def __init__( # that admission boundary; this marker is not persisted in the transcript. self._memory_assignment_from_history = False self.project: str = "" + # A CLEARED project and one never set both leave ``project`` empty, but only a clear + # invalidates a warm pooled child's binding. + self.project_cleared: bool = False # Remote-execution binding. ``executor`` is "local" for every ordinary # slot; "remote" means the turn is dispatched over an instance tunnel to # ``instance_id`` and run by the peer's slot ``remote_slot``. The local @@ -3905,11 +3912,28 @@ def __init__( # still drain) until the plan ends — so autopilot reuses the normal-chat # queue/chip path without changing slot.task / slot.running semantics. self._in_stage_execution: bool = False - # Set by _run_chat's teardown to that turn's ACP auth-required outcome, so - # the orchestrator _stage_loop can mirror the "hold the queue for - # post-login resume" guard on its end-of-plan handoff (a signed-out CLI - # must not pop the held follow-up into another auth failure). - self._last_turn_auth_required: bool = False + # Whether the queue is HELD rather than drained; False drains. Set by + # _run_chat's teardown to that turn's outcome, and read by every drain gate: + # _run_chat's own tail, the synthesis dispatch in _finish_queue_cycle, and + # the orchestrator's two handoffs (_exit_cancelled_plan, _stage_loop's + # finally). Draining NOW serves the queue worse than leaving it: a repeat failure + # for two causes, a wait behind the streaming turn for the third — so they + # stay queued (visible, individually cancellable) and resume on the user's + # next send. The claim is about the QUEUE, not about this turn having run + # nothing: two causes are discovered before a turn runs, but a deferred + # project reset is discovered at the END of a turn that completed normally. + # + # ONE predicate rather than one flag per cause, deliberately. Each cause as + # its own boolean (base: ONE, 6 refs, 3 files, TWO gates) is hand-wired into + # every gate, so adding one risks MISSING one, silently dequeuing and burning + # prompts — which is exactly how the synthesis and stage-handoff gaps got + # shipped. A new cause now composes by setting this flag, and a new drain + # site composes by asking this one question. + # + # A BOOLEAN and not a reason string: the causes today are a signed-out CLI, + # a queued project change refused before the turn, and one left deferred + # after it — and no gate, log line or test ever asked which of them it was. + self._queue_held: bool = False self._recovery_chat_triggered: bool = False # guard against concurrent failure recovery self._stage_titles: list[str] = [] # stage titles extracted from plan self._stage_descriptions: list[list[str]] = [] # bullet points per stage @@ -4129,6 +4153,10 @@ def __init__( # Regenerate feature: variants pending attachment to next finalized assistant message self._pending_variants: list[dict] = [] self._lock = asyncio.Lock() + # Excludes a ``linked_session_key`` rebind while an arm settles onto that key. Not + # ``slot._lock``: see ``chat_utils.settling_key``. A depth, because regions nest. + self._key_settling: int = 0 + self._key_deferred: str | None = None self.forked_from: str | None = None # parent slot key if this is a fork self._fork_lock: asyncio.Lock = asyncio.Lock() # serialises concurrent forks on this slot # Serialises explicit model-pick transactions (check → mutate → live @@ -4392,6 +4420,23 @@ def cancel_close(self) -> None: """Release the admission fence when teardown leaves this slot live.""" self._closing = False + @property + def claim_cwd(self) -> str | None: + """The cwd a claim must state for this slot, or ``None`` to state none. + + ``CWD_CLEARED`` is reserved for a project that was actually cleared, because that is + when a warm pooled child's binding has been invalidated. A slot that never had a + project states nothing, keeping the warm pool and its stored-cwd resume override. + + The cleared MARKER is read before the project, so a value that outlived its clear + cannot win. A persisted record is merged by an upsert that cannot delete a key, so a + slot cleared after `/old` was written still carries `/old` on disk; honoring that + resumes relative writes into the former directory with nothing to signal it. + """ + if getattr(self, "project_cleared", False): + return CWD_CLEARED + return self.project or None + @property def _dirty(self) -> bool: """True while this slot holds state not yet confirmed on disk. @@ -6831,6 +6876,15 @@ def get_or_create_slot( return existing assert creation is not None name = creation.key + # circular import: chat_utils imports state at module scope. + from kiro_crew.dashboard.chat_utils import _history_key_for + + # A new slot on a recycled key inherits nothing: the close/sweep paths run + # ``remove``, which preserves the previous slot's retirement arm by design. + # Guarded like every other `self.sessions` call here -- slot creation must survive a + # state built without a manager, and with none there is no arm to supersede. + if self.sessions: + self.sessions.supersede_arm_for_new_slot(_history_key_for(name)) requested_name = creation.requested_name minted_new = creation.minted_new slot = _ChatSlot( @@ -6907,7 +6961,9 @@ def get_or_create_slot( # that a channel path already claimed. slot.channel_origin = True if linked_session_key: - slot.linked_session_key = linked_session_key + from kiro_crew.dashboard.chat_utils import bind_linked_session_key + + bind_linked_session_key(slot, linked_session_key) elif self.sessions: # No caller-supplied binding, but a channel-stem name means this slot # displays a conversation that runs on the channel's own session. @@ -6925,7 +6981,11 @@ def get_or_create_slot( if is_channel_session_key(name): resolved = self.sessions.channel_key_for_stem(name) if isinstance(resolved, str) and is_channel_session_key(resolved): - slot.linked_session_key = resolved + from kiro_crew.dashboard.chat_utils import ( + bind_linked_session_key, + ) + + bind_linked_session_key(slot, resolved) try: if self.sessions: from kiro_crew.dashboard.chat_utils import effective_session_key diff --git a/src/kiro_crew/dashboard/workflow_inject.py b/src/kiro_crew/dashboard/workflow_inject.py index 9dc86ceec7e..5de5872579c 100644 --- a/src/kiro_crew/dashboard/workflow_inject.py +++ b/src/kiro_crew/dashboard/workflow_inject.py @@ -17,7 +17,7 @@ import re from typing import Any, Callable, Optional -from kiro_crew.dashboard.chat_utils import dashboard_slot_key +from kiro_crew.dashboard.chat_utils import bind_linked_session_key, dashboard_slot_key from kiro_crew.dashboard.state import DashboardState, append_and_surface, row_mid from kiro_crew.history import append_if_absent_off_loop from kiro_crew.security import redact_credentials, redact_exfiltration_urls @@ -153,7 +153,7 @@ def inject_workflow_result( if slot is None: slot = state.get_or_create_slot(name=f"workflow-{run_id}") if not getattr(slot, "linked_session_key", ""): - slot.linked_session_key = session_key + bind_linked_session_key(slot, session_key) slot.title = f"Workflow: {snapshot.get('name') or run_id}" # Dedup: don't double-inject the same result on a re-fire. diff --git a/src/kiro_crew/history.py b/src/kiro_crew/history.py index 0cd0870bd80..565a9562964 100644 --- a/src/kiro_crew/history.py +++ b/src/kiro_crew/history.py @@ -278,7 +278,8 @@ # holder the holder's are the true ones. Deferring them also fails CLOSED where the # line carries none: an absent ``created_by`` denies rather than grants, and an # absent ``origin`` restores to the empty sentinel the rehydrate paths already treat -# that way. +# that way. ``project_cleared`` is the same shape applied to the DIRECTORY: beside another +# slot's ``project`` it resurrects a directory this holder cleared, or clears one it never set. # # What is left out is left out deliberately: ``auto_tagged``, ``human_seen``, # ``channel_origin`` and ``channel_folder_filed`` are MONOTONE once-flags about the @@ -286,7 +287,7 @@ # disagree about them in a way that outlives the pair. ROWS_ONLY_DEFERRED_META_KEYS: frozenset[str] = ( SLOT_OWNED_META_KEYS - ROWS_ONLY_OWNED_META_KEYS -) | frozenset({"title_origin", "title_refresh_mark", "created_by", "origin"}) +) | frozenset({"title_origin", "title_refresh_mark", "created_by", "origin", "project_cleared"}) def carry_unowned_metadata( diff --git a/src/kiro_crew/session.py b/src/kiro_crew/session.py index 4aad0710e73..455542bca44 100644 --- a/src/kiro_crew/session.py +++ b/src/kiro_crew/session.py @@ -123,8 +123,9 @@ default_project_dir, normalize_agent_model, published_autocompact_pct, + resolve_agent_bindings, ) -from kiro_crew.config.paths import config_dir +from kiro_crew.config.paths import config_dir, resolved_cwd from kiro_crew.constants import COMPACT_WAIT_TIMEOUT_SECS from kiro_crew.executors import maintenance_executor, subprocess_executor from kiro_crew.mcp_gateway.abort import schedule_abort @@ -363,6 +364,31 @@ def _provider_effectively_alive(provider: Any) -> bool: return alive +def _resolve_runtime_agent(alias: str, project: str | None = None) -> str: + """Resolve an agent ALIAS to the runtime agent it currently names. + + The retirement arm records the alias, never its target, so the mapping is read + HERE at every consume: an alias re-pointed by a config edit during the arm window + would otherwise hand the retry a frozen agent and run the wrong one. Reads the + hot-path config cache, so no filesystem work reaches the event loop. + + Degrades to the alias itself: an unresolvable name is what the dispatch sites also + fall back to (``kiro_agent or slot.agent``), so both sides stay consistent. + """ + if not alias: + return "" + try: + cfg = KiroCrewConfig.load() + # `resolve_agent_bindings` substitutes the DEFAULT agent for an unknown name, + # which would answer a different identity rather than resolve this one. + if alias not in cfg.agents: + return alias + return resolve_agent_bindings(cfg, alias, project).kiro_agent or alias + except Exception: + logger.warning("Failed to resolve runtime agent for alias %r", alias, exc_info=True) + return alias + + def _provider_uses_kiro_identity_store(provider: Any) -> bool: """Whether *provider*'s child authenticates from kiro-cli's identity store. @@ -900,6 +926,12 @@ class _Session: # the caller's existing stale-provider path evicts it and cold starts. Default # False so every existing construction site is unaffected. retire_on_identity_change: bool = False + # True while the lease is held for a LIFETIME rather than a turn -- see + # ``session_lifecycle._turn_in_flight``, which is what asks. + lifecycle_lease: bool = False + # Set by a lifecycle holder for the whole of its turn, INCLUDING the setup before the + # provider registers one. ``has_active_turn`` cannot see that window. + lifecycle_turn_active: bool = False prompt_count: int = 0 consecutive_failures: int = 0 # Bounded rather than plain: a release() call that lands on this object @@ -1066,6 +1098,7 @@ def _allocation_deps(self) -> AllocationDeps: ), spec_model=lambda spec: spec_model(spec), agent_model_cache=lambda: _get_agent_model_cache(), + resolve_runtime_agent=_resolve_runtime_agent, ) def _allocation_boundary(self) -> SessionAllocationService: @@ -1531,6 +1564,96 @@ def has_session(self, key: str) -> bool: """Return whether a live session exists for the folded key.""" return self._allocation_boundary().has_session(key) + async def resolve_arm_cwd(self, key: str, cwd: str) -> str: + """Resolve an arm's target off-thread, for the CLEARED case that touches disk. + + Resolves only -- it arms nothing, so the caller's arm or transfer stays synchronous + and atomic in its own commit window. A cleared project resolves to the per-session + default, which stats, mkdirs and realpaths the workspace root; on a symlinked or + network root that blocks in the kernel, and every arm site is reached from an async + handler. A non-empty project needs no filesystem work and is handed straight back. + """ + if cwd: + return cwd + return await asyncio.to_thread(resolved_cwd, cwd, self._fold_key(key)) + + def set_lifecycle_turn_active(self, key: str, active: bool) -> bool: + """Record whether a lifecycle holder is taking a turn. + + A holder that keeps its lease across an idle life has to say when it is WORKING, + because the pre-stream setup runs before the provider registers a turn and a probe + reading the provider alone would tear the session down mid-setup. Returns whether a + registered session was updated. + """ + session = self._allocation_boundary()._sessions.get(self._fold_key(key)) + if session is None: + return False + session.lifecycle_turn_active = active + return True + + def mark_lifecycle_lease(self, key: str) -> bool: + """Declare that this key's lease is held for a LIFETIME, not for one turn. + + A holder that keeps the lease across an idle listening life must say so, because a + busy probe reading the lease alone would otherwise refuse every teardown on the key + for as long as the holder exists. Returns whether a registered session was marked. + """ + session = self._allocation_boundary()._sessions.get(self._fold_key(key)) + if session is None: + return False + session.lifecycle_lease = True + return True + + def mark_retire_on_next_claim( + self, key: str, cwd: str | None, *, agent: str | None = None + ) -> int: + """Mark a live session invalid for reuse without disturbing its turn. + + Synchronous by design. Pass ``cwd`` already resolved for a cleared project -- see + :meth:`resolve_arm_cwd` -- because resolving it here would put filesystem work on + the event loop. Returns the arm's generation, which a producer that later has to + unwind passes to :meth:`supersede_arm_for_new_slot` to retract only its own arm. + """ + return self._allocation_boundary().mark_retire_on_next_claim(key, cwd, agent=agent) + + async def note_project_change(self, key: str, cwd: str | None) -> None: + """Supersede an earlier arm's GENERATION and directory, and record the committed one. + + Only those two: a prior arm's ``agent`` and ``requires_sid_clear`` survive, because + neither is provenance a project change can speak for. + + Async because the CLEARED case resolves the per-session default, which stats and + realpaths the workspace root -- synchronous filesystem work that would otherwise + run on the event loop, since every caller is an async handler. Resolving here also + leaves the recording itself synchronous, so no await sits between the generation + bump and the arm it belongs to. + """ + if cwd == "": + folded = self._allocation_boundary()._fold_key(key) + cwd = await asyncio.to_thread(resolved_cwd, cwd, folded) + self._allocation_boundary().note_project_change(key, cwd) + + def supersede_arm_for_new_slot(self, key: str, *, only_generation: int | None = None) -> None: + """Drop an arm left by a previous occupant of a recycled or torn-down slot key. + + Synchronous by design: it resolves nothing, so no filesystem work reaches the event + loop and no await sits between the generation bump and the arm it supersedes. + + ``only_generation`` makes this a retraction of ONE arm: pass the generation + :meth:`mark_retire_on_next_claim` returned, and a key another producer has armed + since is left alone. + """ + self._allocation_boundary().supersede_arm_for_new_slot(key, only_generation=only_generation) + + def transfer_retire_arm(self, from_key: str, to_key: str, cwd: str | None) -> None: + """Move an arm onto the key a rebound slot actually runs on. + + Synchronous by design; pass ``cwd`` pre-resolved via :meth:`resolve_arm_cwd`. + ``None`` states NO directory, which an unset project needs and a cleared one does + not: widening this is what keeps the two from collapsing at the boundary. + """ + self._allocation_boundary().transfer_retire_arm(from_key, to_key, cwd) + def get_provider(self, key: str) -> LLMProvider | None: """Return the live provider for a folded key.""" return self._allocation_boundary().get_provider(key) @@ -1893,12 +2016,14 @@ async def _reacquire_and_validate( sess: "_Session", *, wait_if_busy: bool = True, + cwd: str | None = None, ) -> bool: """Acquire outside the registry lock and revalidate identity.""" return await self._allocation_boundary()._reacquire_and_validate( key, sess, wait_if_busy=wait_if_busy, + cwd=cwd, ) async def _evict_stale_session(self, key: str, sess: "_Session") -> None: @@ -2155,6 +2280,7 @@ async def reset( *, expect_session: _Session | None = None, skip_if_busy: bool = False, + refuse_only_on_active_turn: bool = False, clear_conversation: bool = False, ) -> bool: """Reset a live session while preserving its persistence entry.""" @@ -2162,6 +2288,7 @@ async def reset( key, expect_session=cast(Any, expect_session), skip_if_busy=skip_if_busy, + refuse_only_on_active_turn=refuse_only_on_active_turn, clear_conversation=clear_conversation, ) @@ -2345,7 +2472,12 @@ async def destroy_if( ) async def discard_conversation( - self, key: str, *, replay: bool = True, skip_if_busy: bool = False + self, + key: str, + *, + replay: bool = True, + skip_if_busy: bool = False, + refuse_only_on_active_turn: bool = False, ) -> bool: """Drop native conversation state while retaining channel linkage. @@ -2355,7 +2487,10 @@ async def discard_conversation( atomicity contract. """ return await self._lifecycle_boundary().discard_conversation( - key, replay=replay, skip_if_busy=skip_if_busy + key, + replay=replay, + skip_if_busy=skip_if_busy, + refuse_only_on_active_turn=refuse_only_on_active_turn, ) async def drain_active_turns(self, timeout: float | None = None) -> int: diff --git a/src/kiro_crew/session_allocation.py b/src/kiro_crew/session_allocation.py index 68d042119a0..17f3d0b2e88 100644 --- a/src/kiro_crew/session_allocation.py +++ b/src/kiro_crew/session_allocation.py @@ -19,6 +19,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, cast +from kiro_crew.config.paths import CWD_CLEARED, resolved_cwd from kiro_crew.member_memory_auth import private_memory_store_for_session from kiro_crew.metrics.sessions import ( END_REASON_EVICTED, @@ -109,11 +110,85 @@ class AllocationDeps: get_subprocess_executor: Callable[[], Executor] get_sync_kill_provider: Callable[[], Callable[[LLMProvider], None]] agents_dir_path: Callable[[], Path] + resolve_runtime_agent: Callable[..., str] read_agent_spec: Callable[..., dict[str, Any] | None] spec_model: Callable[[dict[str, Any]], str] agent_model_cache: Callable[[], dict[str, tuple[str, float, float]]] +def cwd_moved_for_reuse(raw_bound: object, cwd: str | None, requested_default: str | None) -> bool: + """Whether a claim's directory disagrees with the one a live session is bound to. + + Extracted so the comparison can be driven directly: it runs on EVERY reuse, and its + failure mode is silent -- a false move evicts a warm session, so the slot cold-starts + each turn or exhausts its retry budget with nothing raised. + + Both sides go through ``resolved_cwd``, and that symmetry is the point. Only one side + normalized makes the answer depend on which side happened through ``Path``: a provider + reporting a raw string disagrees with a caller's normalized one, and on Windows ``Path`` + rewrites separators, so two spellings of one directory compare unequal and every reuse + evicts. Deliberately NOT realpath -- that is a filesystem call under the registry lock, + and the binding is the spelling the session was OPENED with, which is what a claim + restates, so a symlinked root compares equal to itself without resolving it. + """ + # A provider tracking no real directory STRING reports nothing to disagree with, which + # is not the same as reporting no directory. The ABC default is "". + bound_readable = isinstance(raw_bound, str) and raw_bound != "" + bound_cwd, requested_cwd = normalized_reuse_cwds(raw_bound, cwd, requested_default) + # `None` states no requirement; `""` states "the default workspace". + return cwd is not None and bound_readable and requested_cwd != bound_cwd + + +def normalized_reuse_cwds( + raw_bound: object, cwd: str | None, requested_default: str | None +) -> tuple[str, str]: + """The two spellings the reuse comparison is made on, as ``(bound, requested)``. + + One definition, because the claim gate needs the PAIR as well as the verdict: the arm + agreement compares against these values rather than against the boolean, and a second + copy of the normalization beside the call would drift from this one silently. + """ + bound_cwd = resolved_cwd(raw_bound) if isinstance(raw_bound, str) and raw_bound else "" + requested_cwd = ( + "" if cwd is None else (requested_default if cwd == "" else resolved_cwd(cwd)) + ) or "" + return bound_cwd, requested_cwd + + +@dataclass(slots=True) +class RetireArm: + """One key's retirement arm, and the generation that outlives it. + + The three facts share a key and NOT a lifetime: spending an arm drops the directory and the + agent it names, while the generation must survive, because a start compares itself against + that counter to learn it is stale. Holding them in one record makes that asymmetry a single + method rather than an invariant every caller has to remember. + + ``cwd`` is already through ``resolved_cwd``, so a cleared project is the concrete + default-workspace path and never an empty string. ``None`` means the arm states NO + directory, which is not the same as stating the default -- see ``_record_arm_cwd``. + + ``requires_sid_clear`` is provenance a directory cannot carry. A discard bumps the + generation without moving the project, so an arm can hold a resolved cwd from an EARLIER + project change while naming a conversation that was thrown away. A retry reading only the + directory then states a real path, which is not ``CWD_CLEARED``, so the resume guard does + not fire and the discarded conversation comes back. Dropped by ``spend`` with the other + per-episode facts: the next conversation has its own SID, and a flag that outlived its + episode would wipe that one instead. + """ + + generation: int = 0 + cwd: str | None = None + agent: str | None = None + requires_sid_clear: bool = False + + def spend(self) -> None: + """Drop what the arm names. The generation is deliberately untouched.""" + self.cwd = None + self.agent = None + self.requires_sid_clear = False + + @dataclass(slots=True) class SessionRegistryState: """Mutable state exclusively owned by the allocation boundary.""" @@ -129,6 +204,10 @@ class SessionRegistryState: subagent_runtime_locks: dict[str, asyncio.Lock] = field(default_factory=dict) continuable_keys: set[str] = field(default_factory=set) continuable_fallback: Callable[[str], bool] | None = None + # Keys whose next claim must NOT be served a reused session, armed when a + # teardown was refused. Keyed by string rather than by session object so it + # still covers a cold start that has not registered yet. + retire_arms: dict[str, RetireArm] = field(default_factory=dict) class _AllocationOwner(Protocol): @@ -167,6 +246,7 @@ async def _reacquire_and_validate( session: Any, *, wait_if_busy: bool = True, + cwd: str | None = None, ) -> bool: ... async def _evict_stale_session(self, key: str, session: Any) -> None: ... @@ -324,6 +404,30 @@ def _continuable_keys(self) -> set[str]: def _continuable_keys(self, value: set[str]) -> None: self.state.continuable_keys = value + def _arm(self, folded: str) -> RetireArm: + """This key's record, created empty if absent. For a WRITE.""" + return self.state.retire_arms.setdefault(folded, RetireArm()) + + def _arm_if_any(self, folded: str) -> RetireArm | None: + """This key's record, or ``None``. For a READ, which must not create one.""" + return self.state.retire_arms.get(folded) + + def _generation(self, folded: str) -> int: + """This key's monotonic counter, bumped every time its project changes. Zero if absent. + + Staleness is an ORDERING question -- did this provider start before the change + it must honour -- and the directory a caller states cannot answer it: a cold + start begun before the change and a slot recreated under the same name after it + both state something other than the armed target. Comparing the generation the + provider started at against the current one separates them, so a recreated slot + keeps its own project instead of inheriting the previous slot's. + + A read never creates a record, so a key that was never armed reports zero rather + than gaining an entry that ``discard_all_retire_arms`` would then have to clear. + """ + arm = self._arm_if_any(folded) + return arm.generation if arm is not None else 0 + @property def _continuable_fallback(self) -> Callable[[str], bool] | None: return self.state.continuable_fallback @@ -351,6 +455,221 @@ def owned(candidate: str) -> bool: def has_session(self, key: str) -> bool: return self._owner._fold_key(key) in self._sessions + def spend_retire_arm(self, key: str) -> None: + """Drop *key*'s retirement arm, for a teardown that ENDS the slot generation. + + Called only where the slot itself is gone -- ``destroy``, which deletes the + session-map entry. The arm names a directory a SUCCESSOR must bind, so once no + successor of that slot can arrive the only claim left to apply it to is a + different slot recreated under the same name, which would silently inherit the + previous project. + + Cleanup that keeps the slot (``remove``, ``remove_if_unclaimed``) deliberately + calls nothing: the arm is still owed to a real claim, and it doubles as the retry + target for a start the cleanup evicts, whose own frame carries only the + pre-change directory. + + The key's GENERATION is never dropped here. That counter is what refuses a start + still in flight, and a teardown it must survive is exactly the case where the arm + itself has to go. + """ + folded = self._owner._fold_key(key) + if (arm := self._arm_if_any(folded)) is not None: + arm.spend() + + def supersede_arm_for_new_slot(self, key: str, *, only_generation: int | None = None) -> None: + """Release *key*'s arm because the slot that owned it is gone for good. + + Two seams call this, and neither teardown verb can: ``destroy`` spends the arm, but + the close and sweep paths run ``remove``, which preserves it deliberately -- it is + still owed to a start the cleanup evicts, whose own frame carries only the pre-change + directory. So the arm legitimately outlives the SESSION while the SLOT is gone. + + Slot MINT is the first seam: a freshly minted slot has never been armed, so any arm + on its key belongs to a previous occupant, whose relative writes would otherwise land + in that occupant's project. Slot REUSE returns before this is reached, keeping the + retry target ``remove`` preserves. FINAL slot teardown is the second: without it a + slot closed and never recreated left both PENDING entries resident until process + exit, so on a long-lived gateway every transient key that saw a change accumulated. + + Reclaims the two pending maps only. The GENERATION is bumped and RETAINED, never + dropped: clearing would let a start still in flight from the previous occupant + compare equal to a fresh key's zero and bind here. One integer per key the process + has armed therefore stays resident, which is the cost of that guard. + + ``only_generation`` narrows this to a RETRACTION of one specific arm, for a producer + unwinding its own work. The seams above pass nothing, because a slot that is gone + invalidates every arm on its key regardless of who wrote it. A producer that armed + and then had to unwind passes the generation + :meth:`mark_retire_on_next_claim` returned it; if the resident generation has moved + on, another producer armed this key inside the unwinding request's await window, so + the arm named here is already superseded. That case RETURNS EARLY: nothing is dropped + and the generation is left alone, because bumping it would invalidate the other + producer's in-flight start -- the same cross-project write this scoping prevents. + + Whenever the call does proceed -- every unscoped caller, and a retraction whose + generation still matches -- it BUMPS the generation before spending the arm. On the + matched path the resident generation is this producer's own, so there is no other + producer's start to invalidate, and the bump is what makes this producer's own + in-flight start read as stale at registration instead of being served. + """ + folded = self._owner._fold_key(key) + if only_generation is not None and self._generation(folded) != only_generation: + return + arm = self._arm(folded) + arm.generation += 1 + arm.spend() + + def discard_all_retire_arms(self) -> None: + """Drop every arm and generation, for a shutdown that retires all keys at once.""" + self.state.retire_arms.clear() + + def note_conversation_discarded(self, key: str) -> None: + """Bump this key's generation because its conversation was thrown away. + + The clear path treats "nothing registered" as already-cleared and answers True, but + a cold start in flight has CACHED its resume SID and not registered yet, so clearing + the persisted map does not reach it: it registers afterwards carrying the very + conversation the caller was told was gone. The generation is what a start compares + itself against at registration, so bumping it here is what makes that late arrival + read as stale and retire instead of being served. + + Arms no cwd, unlike ``note_project_change``: a clear moves no directory, so the + binding a retry must honour is unchanged and recording one would misdirect it. It does + record that the SID must go, which the directory cannot express -- an arm may still + hold a resolved cwd from an earlier project change, and a retry that reads only the + directory states a real path rather than ``CWD_CLEARED``, so the resume guard does not + fire and the discarded conversation is served again. + """ + folded = self._owner._fold_key(key) + arm = self._arm(folded) + arm.generation += 1 + arm.requires_sid_clear = True + + def _record_arm_cwd(self, folded: str, cwd: str | None) -> None: + """Record the directory an arm states, or that it states NONE. + + ``None`` is not ``CWD_CLEARED``. A never-scoped slot's claim states no directory, so + the warm pool and its stored-cwd resume override still apply; resolving ``None`` here + would arm the per-session default that claim deliberately does not ask for, and the + next turn's relative writes would land outside the directory it was resuming. The + entry is DROPPED rather than left, so an earlier change's directory cannot outlive + the generation bump that supersedes it. + """ + if cwd is None: + self._arm(folded).cwd = None + return + self._arm(folded).cwd = resolved_cwd(cwd, folded) + + def note_project_change(self, key: str, cwd: str | None) -> None: + """Record that this key's project moved TO ``cwd``, without arming a refusal. + + Not every project change raises an arm: the agent and workspace switches commit a + new project and tear the session down directly. The generation bump alone is not + enough, because a start already in flight is then evicted and RETRIED -- and the + retry re-reads the cwd its own frame was called with, which is the pre-switch one. + So the committed directory is recorded with the generation and becomes what the + retry binds. Without it a generation-only switch evicts a provider and replaces it + with another bound to the project the user just left. + + Distinct from ``mark_retire_on_next_claim`` only in not flagging a REGISTERED + session for retirement: these callers tear their session down themselves. + """ + folded = self._owner._fold_key(key) + generation = self._generation(folded) + 1 + self._arm(folded).generation = generation + self._record_arm_cwd(folded, cwd) + + def mark_retire_on_next_claim( + self, key: str, cwd: str | None, *, agent: str | None = None + ) -> int: + """Mark this key's session invalid for reuse without touching a running turn. + + For a teardown that had to be REFUSED. The refusal keeps a streaming reply + alive, but the reason for the teardown does not go away, so the next claim + must not be handed that session either. + + Records the KEY, not just the object, because "no session registered" is NOT + the same as "nothing to protect against": a cold start holds no registry entry + until it finishes, so a probe during that window sees nothing while a provider + bound to the pre-change directory is already on its way. Pinning only a + registered object would miss exactly that case. `_reacquire_and_validate` + consumes the key, so a session that registers AFTER this call is refused on + its next claim just the same. + + What this can and cannot promise: a turn already streaming keeps its provider + (that is what the refusal is FOR, and tearing it down mid-reply is the harm + `skip_if_busy` exists to prevent), so the guarantee is that no LATER turn is + served the stale session -- not that an in-flight one is retro-corrected. + + `retire_on_identity_change` is the flag `session_lifecycle` already sets when + its own teardown finds the session locked, so the registered case reuses that + path rather than adding a second one. + + Returns the GENERATION recorded here. That is what lets a producer retract its + OWN arm and nothing else: it passes the value back to + :meth:`supersede_arm_for_new_slot`, whose drop then no-ops once another producer has + armed the same key in between. Whether a session happened to be registered is still + unreported -- the arm covers both shapes -- so that would be a value with no reader. + """ + folded = self._owner._fold_key(key) + # The generation is bumped and the pending maps OVERWRITTEN, so a LATER change + # to the same key supersedes this arm rather than leaving two live answers. + generation = self._generation(folded) + 1 + self._arm(folded).generation = generation + self._record_arm_cwd(folded, cwd) + if agent is not None: + self._arm(folded).agent = agent or "kirocrew" + session = self._sessions.get(folded) + if session is not None: + session.retire_on_identity_change = True + return generation + + def transfer_retire_arm(self, from_key: str, to_key: str, cwd: str | None) -> None: + """Re-point an arm from an ABANDONED key onto the one the slot now runs on. + + For a slot that REBOUND between arming and consuming. Arming the live key alone + would leave the abandoned key's arm in the map, where nothing drops it: an arm is + cleared by the claim that satisfies it, and no claim arrives for a key the slot + left. The map is keyed by STRING, so a later session under that same key reads an + arm naming a directory chosen for a binding that is gone. + + Distinct from :meth:`spend_retire_arm`, which is for a teardown that ENDS the slot + generation and so drops an arm no successor is owed; here the arm is still owed and + only its ADDRESS was wrong. One synchronous step, so the requirement is never + recorded twice or not at all. The agent travels with it because only the arm states + which agent a successor must run. ``from_key``'s GENERATION stays, for the reason + :meth:`spend_retire_arm` keeps its own -- see the rebind producer in + ``docs/system-specs/modules/session.md``. + + An EQUIVALENT same-key re-arm -- same folded key, already holding the same resolved + target -- keeps the generation rather than bumping it. The deferred-reset retry + re-enters every few seconds while sub-agents stay attached, and each bump rejects a + cold start still resolving its own model, so a slow start is refused on every pass + until it gives up. Equivalence is deliberately narrow: a DIFFERENT target on the + same key is a genuine re-arm and must supersede. The agent needs no comparison in + that case because ``carried_agent`` is read from this very key, so it cannot differ. + """ + source = self._owner._fold_key(from_key) + target = self._owner._fold_key(to_key) + source_arm = self._arm_if_any(source) + carried_agent = source_arm.agent if source_arm is not None else None + target_arm = self._arm_if_any(target) + target_cwd = target_arm.cwd if target_arm is not None else None + if source == target and target_cwd == ( + resolved_cwd(cwd, target) if cwd is not None else None + ): + # The retry loop re-arms the SAME key with the SAME target every few seconds. + # Bumping the generation there rejects an in-flight cold start on every pass. + session = self._sessions.get(target) + if session is not None: + session.retire_on_identity_change = True + return + self.mark_retire_on_next_claim(to_key, cwd, agent=carried_agent) + if source != target: + if source_arm is not None: + source_arm.spend() + def get_provider(self, key: str) -> LLMProvider | None: session = self._sessions.get(self._owner._fold_key(key)) return session.provider if session else None @@ -546,26 +865,148 @@ async def _reacquire_and_validate( session: Any, *, wait_if_busy: bool = True, + cwd: str | None = None, ) -> bool: - """Acquire with the global lock released, then validate exact identity.""" + """Acquire with the global lock released, then validate exact identity. + + ``cwd`` also validates the session's BOUND directory. It is applied when a + provider is CREATED and never re-applied, so a live session whose project has + since changed would otherwise be handed back bound to the OLD directory and + the turn's relative writes would land there. + + Checked HERE rather than at the reuse decision because this runs with the + semaphore HELD: any turn that was streaming on this provider has finished, so + returning False -- which sends the caller through ``_evict_stale_session`` -- + cannot tear a live reply down mid-stream. The reuse decision runs before the + semaphore is claimed, where that guarantee does not hold. + + Validating it here also covers every reuse path in this class rather than only + the callers that remember to ask: all four claims -- both in ``get_or_create`` + and both in ``open_task_session`` -- pass their own ``cwd`` through this one + helper. A caller that passes none states no requirement and cannot mismatch. + + Guarded on BOTH being set, mirroring the pool gate's own comparison: a caller + passing no ``cwd`` states no requirement and must not evict a session serving + others correctly. The isinstance check is that rule applied to the other side + -- a provider tracking no real directory string reports no binding to + disagree with, and evicting on an unreadable one would churn every reuse + rather than protect anything. + """ if not wait_if_busy and session.semaphore.locked(): raise SessionBusyError(key) - # An idle Semaphore(1) acquires without suspension, so this is the - # authoritative non-waiting claim boundary after the locked check. + # Resolved off-thread BEFORE the lock: the cleared case stats and realpaths the + # workspace root, and synchronous I/O with the registry held wedges every session. + requested_default = await asyncio.to_thread(resolved_cwd, cwd, key) if cwd == "" else None + # The armed alias resolves through a CONFIG LOAD, which on a cache miss reads and + # schema-validates -- offloaded here because the decision window below takes no await. + pre_armed = self._arm_if_any(key) + pre_armed_alias = pre_armed.agent if pre_armed is not None else None + pre_armed_target = ( + await asyncio.to_thread(self._deps.resolve_runtime_agent, pre_armed_alias, None) + if pre_armed_alias + else None + ) + # Re-checked AFTER the awaits above: two suspension points separate the first check + # from the acquire, so a caller that asked never to block would block here. + if not wait_if_busy and session.semaphore.locked(): + raise SessionBusyError(key) + # An idle Semaphore(1) acquires without suspension, so with the re-check directly + # above this is the authoritative non-waiting claim boundary. await session.semaphore.acquire() + cwd_moved = False try: async with self._lock: + raw_bound = getattr(session.provider, "cwd", None) + bound_readable = isinstance(raw_bound, str) and raw_bound != "" + # The same normalization the comparison makes, from ONE definition: the arm + # agreement below compares against these spellings, not against the verdict. + bound_cwd, requested_cwd = normalized_reuse_cwds(raw_bound, cwd, requested_default) + # One definition, driven directly by its own tests: the symmetry this + # comparison depends on is invisible at the call site. + cwd_moved = cwd_moved_for_reuse(raw_bound, cwd, requested_default) + cwd_stated = cwd is not None + reg_arm = self._arm_if_any(key) + armed_target = reg_arm.cwd if reg_arm is not None else None + armed_agent = reg_arm.agent if reg_arm is not None else None + retire_armed = armed_target is not None or armed_agent is not None + # Only the REGISTERED session interacts with the arm: a claimant holding a + # session already replaced would otherwise spend it for the successor. + is_registered = self._sessions.get(key) is session + cwd_satisfied = ( + is_registered + and bound_readable + and ( + ( + cwd_stated + and requested_cwd == bound_cwd + and (armed_target is None or bound_cwd == armed_target) + ) + # A cwd-LESS claim cannot state agreement, so its BINDING settles it. + # An arm with no directory states no requirement to satisfy. + or (not cwd_stated and (armed_target is None or bound_cwd == armed_target)) + ) + ) + # A SEPARATE question from the directory, so it needs its own answer: a + # project-scope switch keeps the directory. See session.md. + # The arm names an ALIAS and the session records the RESOLVED agent, so the + # target is the value pre-resolved off-thread, used only while the arm holds it. + armed_target_agent = pre_armed_target if pre_armed_alias == armed_agent else None + agent_satisfied = armed_agent is None or (session.agent or "kirocrew") in ( + armed_agent, + armed_target_agent or armed_agent, + ) + retire_applies = retire_armed and not (cwd_satisfied and agent_satisfied) + if retire_applies: + # This frame can refuse WITHOUT evicting, so a claim racing the + # registration would find the arm gone and reuse the stale provider. + session.retire_on_identity_change = True + claim_answers_arm = cwd_satisfied and agent_satisfied still_valid = ( self._sessions.get(key) is session and not session.retire_on_identity_change + and not retire_applies and self._deps.provider_effectively_alive(session.provider) + and not cwd_moved ) + if claim_answers_arm and still_valid: + # Spent on ACCEPTANCE, not on satisfaction: a rejected claim (dead + # provider, moved key) must leave the arm for its replacement to pay. + if reg_arm is not None: + reg_arm.spend() except BaseException: # The held-semaphore contract was never returned to the caller. session.semaphore.release() raise if not still_valid: - session.semaphore.release() + try: + if cwd_moved: + # Tear down BEFORE the permit is released, and ONLY for a moved + # directory. Releasing first leaves the session still registered + # with a FREE permit, so another acquirer can win it and be + # mid-command when the eviction shuts its provider down. Only this + # reason exposes that window: it is the one invalidity that fires + # on a LIVE, registered, otherwise-usable provider, whereas a + # session whose identity already moved is not the registry + # occupant and a dead process has nothing to hand out. Popping + # under the permit means a racing acquirer finds no entry and + # cold-starts instead. + # + # Scoped rather than unconditional because the other reasons have + # callers that deliberately do NOT evict -- `recycle_background` + # returns and leaves the entry in place, since tearing it down + # there would kill a session another path already owns. + await self._evict_stale_session(key, session) + finally: + # In a `finally` so a cancellation while the eviction awaits the + # registry lock cannot leave this permit held: that would wedge the + # key for every later turn, which is worse than any window it closes. + # Safe to release even if the eviction was interrupted before popping, + # because the check is IDEMPOTENT -- the directory still does not + # match, so the next claim re-detects it and evicts again. NOT + # `asyncio.shield`: shielding would let the eviction keep running + # while this frame released, which is exactly the release-before-pop + # ordering the branch above exists to prevent. + session.semaphore.release() return still_valid async def _evict_stale_session(self, key: str, session: Any) -> None: @@ -576,6 +1017,8 @@ async def _evict_stale_session(self, key: str, session: Any) -> None: del self._sessions[key] self.advance_ownership_generation(key) dead = session.provider + # The arm is NOT spent here: eviction is not acceptance, and a successor + # can register under it and die before serving. Only a live claim spends it. # Same tick as the removal. Left unrecorded, the start crumb # survives and the next boot calls this a crash. await record_session_ended(key, end_reason=END_REASON_EVICTED) @@ -629,7 +1072,7 @@ async def open_task_session( if approval_policy: existing.approval_policy = approval_policy if existing is not None: - if await owner._reacquire_and_validate(key, existing): + if await owner._reacquire_and_validate(key, existing, cwd=cwd): return existing.provider, False, False await owner._evict_stale_session(key, existing) @@ -688,7 +1131,7 @@ async def open_task_session( "open_task_session: duplicate session teardown failed", exc_info=True, ) - if await owner._reacquire_and_validate(key, session): + if await owner._reacquire_and_validate(key, session, cwd=cwd): return session.provider, False, False await owner._evict_stale_session(key, session) maximum = self._deps.constants.won_race_max_retries @@ -1217,6 +1660,9 @@ async def _get_or_create_impl( owner = self._owner constants = self._deps.constants key = owner._fold_key(key) + # Snapshotted before the FIRST await: everything after is part of this start, so a + # generation bump landing in there must outrank it. + started_generation = self._generation(key) # A binding can belong to any session kind (cron, delegated run, or # consolidation), and must be checked before even reusing a live client. private_memory = bool(await asyncio.to_thread(private_memory_store_for_session, key)) @@ -1243,6 +1689,14 @@ async def _get_or_create_impl( "Session runtime memory isolation does not match its trusted binding; " "restart the session before continuing" ) + # The cwd match is NOT checked here. This runs under the registry + # lock but BEFORE the session semaphore is claimed, so a turn can + # be streaming on this provider right now -- evicting and shutting + # it down from here would tear a live reply down mid-stream, which + # is the very harm the deferred reset above exists to prevent. + # ``_reacquire_and_validate`` checks it instead: that runs with the + # semaphore HELD, so any turn that was streaming has finished and + # the eviction cannot land under one. See its own comment. alive = session.provider.is_process_alive() if not alive: if ( @@ -1307,6 +1761,7 @@ async def _get_or_create_impl( key, session, wait_if_busy=wait_if_busy, + cwd=cwd, ): first_turn = session.first_turn if not speculative: @@ -1333,6 +1788,11 @@ async def _get_or_create_impl( ) and not owner._is_continuable_key(key) if not is_stateless: resume_sid = owner._session_map.get(key) + if resume_sid and cwd == CWD_CLEARED: + # The pool bypass below only stops a cleared claim taking a warm child; resuming + # the stored SID would reinstate the conversation the clear was asked to drop. + owner._session_map.clear_sid(key) + resume_sid = None if speculative and resume_sid and not speculative_resume: raise SpeculativeResumeRefused(key) @@ -1349,7 +1809,9 @@ async def _get_or_create_impl( owner._pool_cwd, ) provider_switched = False - cwd_blocks_pool = bool(cwd and cwd != owner._pool_cwd) + # ``None`` states no preference, so the pool's shared binding is fine, but + # CWD_CLEARED names the PER-SESSION default that no pooled child can be in. + cwd_blocks_pool = cwd == CWD_CLEARED or bool(cwd and cwd != owner._pool_cwd) if not owner._pool_size: pool_decision = "disabled" elif private_memory: @@ -1508,7 +1970,9 @@ def resolve_claim_watchdog() -> tuple[str, object]: raise else: effective_cwd = cwd - if not effective_cwd and resume_sid: + # `is None` and not falsy: an explicit `""` is a CLEARED project, and + # restoring the persisted directory over it re-binds the old one forever. + if effective_cwd is None and resume_sid: stored_cwd = owner._session_map.get_cwd(key) if stored_cwd and Path(stored_cwd).is_dir(): effective_cwd = stored_cwd @@ -1571,7 +2035,17 @@ def resolve_claim_watchdog() -> tuple[str, object]: self._starting_pids.add(starting_pid) won_race_session: Any | None = None + stale_generation = False duplicate_provider: LLMProvider | None = None + # Resolved BEFORE the registry lock: the alias resolve loads config, and holding the + # lock across that read is what wedges every other session (see the claim path). + cold_arm = self._arm_if_any(key) + cold_armed_alias = cold_arm.agent if cold_arm is not None else None + cold_armed_target = ( + await asyncio.to_thread(self._deps.resolve_runtime_agent, cold_armed_alias, None) + if cold_armed_alias + else None + ) try: resumed = False if self._deps.is_acp_provider(provider): @@ -1649,7 +2123,33 @@ def resolve_claim_watchdog() -> tuple[str, object]: ) provider_cwd = provider.cwd - if not is_stateless and self._deps.is_acp_provider(provider): + claim_arm = self._arm_if_any(key) + armed_cwd = claim_arm.cwd if claim_arm is not None else None + armed_agent = claim_arm.agent if claim_arm is not None else None + # The arm holds an ALIAS while the retry re-points through the resolver, + # so both spellings must satisfy it. Resolved off-thread before the lock. + cold_target = ( + cold_armed_target if cold_armed_alias == armed_agent else armed_agent + ) + agent_contradicts_arm = armed_agent is not None and ( + agent or "kirocrew" + ) not in ( + armed_agent, + cold_target or armed_agent, + ) + if ( + started_generation < self._generation(key) + or (armed_cwd is not None and armed_cwd != resolved_cwd(provider_cwd, key)) + or agent_contradicts_arm + ): + # Started before the change, bound where the arm contradicts, or + # running a switched-away agent -- see session.md for why all three. + stale_generation = True + session.retire_on_identity_change = True + # A STALE start's SID names the conversation the change discarded, and + # eviction happens later, so persisting it lets the retry resume it. + keep_sid = not stale_generation + if keep_sid and not is_stateless and self._deps.is_acp_provider(provider): sid = cast(Any, provider).client._session_id provider_label = self._deps.provider_label(provider) if sid: @@ -1659,7 +2159,7 @@ def resolve_claim_watchdog() -> tuple[str, object]: provider=provider_label, cwd=provider_cwd, ) - elif not is_stateless and self._deps.is_claude_provider(provider): + elif keep_sid and not is_stateless and self._deps.is_claude_provider(provider): sid = provider.session_id if sid: owner._session_map.set( @@ -1684,6 +2184,57 @@ def resolve_claim_watchdog() -> tuple[str, object]: if starting_pid is not None: self._starting_pids.discard(starting_pid) + if stale_generation: + # This frame already holds the new session's semaphore, so the won-race + # branch below cannot serve it: that path re-acquires and would self-block. + try: + await owner._evict_stale_session(key, session) + finally: + # The eviction AWAITS the registry lock, so a cancellation there would + # leave this session registered holding a permit nothing ever releases. + session.semaphore.release() + maximum = constants.won_race_max_retries + if _won_race_retries >= maximum: + raise RuntimeError( + f"get_or_create({key!r}) exceeded {maximum} won-race retries — " + "a cold start kept starting before the project change it must honour" + ) + # Re-pointed at the arm like `cwd` below: the retry must be able to SATISFY what + # refused it, or the budget runs out and the slot wedges. + retry_arm_rec = self._arm_if_any(key) + retry_agent = retry_arm_rec.agent if retry_arm_rec is not None else None + if retry_agent is None: + retry_target = agent + elif retry_agent == cold_armed_alias: + retry_target = cold_armed_target or retry_agent + else: + # A SECOND switch re-armed the key AFTER the resolve above, so this alias has + # no resolved target yet and the raw name would reach the provider as a mode. + retry_target = ( + await asyncio.to_thread(self._deps.resolve_runtime_agent, retry_agent, None) + or retry_agent + ) + retry_arm = retry_arm_rec.cwd if retry_arm_rec is not None else None + if retry_arm_rec is not None and retry_arm_rec.requires_sid_clear: + # A cwd left by an earlier project change is not CWD_CLEARED, so the resume + # guard below would not fire; the discard's own clear_sid may not have landed. + owner._session_map.clear_sid(key) + retry_arm_rec.requires_sid_clear = False + return await owner.get_or_create( + key, + agent=retry_target, + channel_id=channel_id, + approval_policy=approval_policy, + model=model, + cwd=(retry_arm if retry_arm is not None else cwd), + extra_env=extra_env, + speculative=speculative, + speculative_resume=speculative_resume, + wait_if_busy=wait_if_busy, + _won_race_retries=_won_race_retries + 1, + **extra_factory_kwargs, + ) + if won_race_session is not None: if duplicate_provider is not None: try: @@ -1698,6 +2249,7 @@ def resolve_claim_watchdog() -> tuple[str, object]: key, won_race_session, wait_if_busy=wait_if_busy, + cwd=cwd, ): first_turn = won_race_session.first_turn if not speculative: @@ -1713,13 +2265,18 @@ def resolve_claim_watchdog() -> tuple[str, object]: f"get_or_create({key!r}) exceeded {maximum} won-race retries — " "session kept going stale between acquire and re-validate" ) + # The armed directory outranks the cwd this frame was called with: that one + # was read before the change, so reusing it would re-lose the same race. + live_rec = self._arm_if_any(key) + live_arm = live_rec.cwd if live_rec is not None else None + retry_cwd = live_arm if live_arm is not None else cwd return await owner.get_or_create( key, agent=agent, channel_id=channel_id, approval_policy=approval_policy, model=model, - cwd=cwd, + cwd=retry_cwd, extra_env=extra_env, speculative=speculative, speculative_resume=speculative_resume, diff --git a/src/kiro_crew/session_lifecycle.py b/src/kiro_crew/session_lifecycle.py index 9542a70e7f1..9e89e93072b 100644 --- a/src/kiro_crew/session_lifecycle.py +++ b/src/kiro_crew/session_lifecycle.py @@ -95,6 +95,9 @@ class SessionLifecycleOwner(Protocol): _compact_cooldown_until: MutableMapping[str, float] _compact_pending_verdict: MutableMapping[str, float] + + def _allocation_boundary(self) -> Any: ... + _cleanup_task: asyncio.Task[Any] | None _background_tasks: set[asyncio.Task[Any]] @@ -146,6 +149,7 @@ async def reset( *, expect_session: _SessionEntry | None = None, skip_if_busy: bool = False, + refuse_only_on_active_turn: bool = False, clear_conversation: bool = False, ) -> bool: ... @@ -214,6 +218,35 @@ class SessionLifecycleState: on_recycled: _RecycleCallback | None = None +def _turn_in_flight(session: Any, *, refuse_only_on_active_turn: bool = False) -> bool: + """Whether *session* is busy, as this caller's ``skip_if_busy`` means it. + + A held lease is the default answer, and the stricter one: it also covers a turn that has + acquired but put no prompt in flight yet, which ``has_active_turn`` cannot see, and it is + what a background sweep needs. A channel member holds its lease for the whole listening + lifetime and CACHES the provider it was handed, so a sweep that tore that provider down + would leave every later message driving a dead one with nothing to re-fetch it. + + A caller acting on an explicit user request passes ``refuse_only_on_active_turn`` and gets + the narrower question instead: refusing a lifecycle holder on the lease alone would refuse + it for as long as it exists, so the retry-when-idle such a caller offers could never + succeed. + """ + if session is None or not session.semaphore.locked(): + return False + if not refuse_only_on_active_turn or not getattr(session, "lifecycle_lease", False): + return True + # The holder's own answer comes first: its turn begins when it dequeues a message, and the + # setup before the prompt goes out is a window ``has_active_turn`` reports as idle. + if getattr(session, "lifecycle_turn_active", False): + return True + provider = getattr(session, "provider", None) + has_active_turn = getattr(provider, "has_active_turn", None) + # An unknown provider shape keeps the strict answer: refusing a teardown is recoverable, + # tearing down a streaming reply is not. + return bool(has_active_turn()) if callable(has_active_turn) else True + + class SessionLifecycleService: """Coordinate provider retirement while preserving facade dispatch seams.""" @@ -423,9 +456,16 @@ async def reset( *, expect_session: _SessionEntry | None = None, skip_if_busy: bool = False, + refuse_only_on_active_turn: bool = False, clear_conversation: bool = False, ) -> bool: - """Kill a live session while preserving the exact reset semantics.""" + """Kill a live session while preserving the exact reset semantics. + + ``refuse_only_on_active_turn`` narrows ``skip_if_busy`` to a DECLARED turn, which a + caller acting on an explicit user request needs: a channel member holds its lease for + its whole listening life, so refusing on the lease alone refuses that caller forever + and the retry-when-idle it offers can never succeed. + """ owner = self._owner logger = self._deps.logger key = owner._fold_key(key) @@ -433,7 +473,9 @@ async def reset( current = owner._sessions.get(key) if expect_session is not None and current is not expect_session: return False - if skip_if_busy and current is not None and current.semaphore.locked(): + if skip_if_busy and _turn_in_flight( + current, refuse_only_on_active_turn=refuse_only_on_active_turn + ): return False session = owner._sessions.pop(key, None) owner._advance_session_generation(key) @@ -797,6 +839,9 @@ async def destroy( # conditional mode preserves this independently owned sidecar. if not preserve_autocompact_override: owner.set_autocompact_pct(key, None) + # The slot itself is gone -- the session-map entry goes with it -- so no + # successor can arrive to pay the arm and it must not outlive them. + owner._allocation_boundary().spend_retire_arm(key) # _origin_links deliberately survives destroy; existing callers # rely on the historical asymmetry with reset/remove. # The map delete is the destructive persistence linearization point. @@ -838,7 +883,12 @@ async def destroy_if( ) async def discard_conversation( - self, key: str, *, replay: bool = True, skip_if_busy: bool = False + self, + key: str, + *, + replay: bool = True, + skip_if_busy: bool = False, + refuse_only_on_active_turn: bool = False, ) -> bool: """Drop only the native conversation while preserving channel linkage. @@ -869,10 +919,15 @@ async def discard_conversation( key = owner._fold_key(key) async with owner._lock: current = owner._sessions.get(key) - if skip_if_busy and current is not None and current.semaphore.locked(): + if skip_if_busy and _turn_in_flight( + current, refuse_only_on_active_turn=refuse_only_on_active_turn + ): return False session = owner._sessions.pop(key, None) owner._advance_session_generation(key) + # Regardless of what the pop found: a cold start that cached its resume SID has + # not registered, so an ABSENT session is exactly the case this covers. + owner._allocation_boundary().note_conversation_discarded(key) owner._compact_cooldown_until.pop(key, None) owner._compact_pending_verdict.pop(key, None) # Store replay suppression atomically with the pop. Origin-link @@ -1104,6 +1159,8 @@ async def close_all(self, drain_timeout: float | None = None) -> None: owner._compact_cooldown_until.clear() self._suppress_replay.clear() owner._compact_pending_verdict.clear() + closing_alloc = owner._allocation_boundary() + closing_alloc.discard_all_retire_arms() # Same lock hold as the clear: the whole drained set is accounted for # in one call, so the awaited unlink cannot be cancelled between two # keys. Per-key awaits would leave every key after the cancellation diff --git a/src/kiro_crew/slack/gateway.py b/src/kiro_crew/slack/gateway.py index 4788aabc3f6..665f5f7e6b3 100644 --- a/src/kiro_crew/slack/gateway.py +++ b/src/kiro_crew/slack/gateway.py @@ -87,10 +87,10 @@ CRED_WECOM_BOT_ID, CRED_WECOM_SECRET, CRED_WEIXIN_TOKEN, - _session_work_dir, build_provider_factory, config_dir, data_home, + session_default_cwd, ) from kiro_crew.config.paths import kiro_agents_dir from kiro_crew.constants import DATA_WARNING, SUBAGENT_COMPLETION_META_KEY, strip_control_comments @@ -9204,7 +9204,7 @@ async def _task_notify( sessions=self.sessions, context_builder=self.ctx_builder, on_notify=_task_notify, - work_dir=_session_work_dir("taskrunner:main"), + work_dir=session_default_cwd("taskrunner:main"), conversation_log=self.conv_log, consolidator=self.consolidator, lesson_store=LessonStore(), @@ -9759,7 +9759,7 @@ async def _init_mcp_gateway(self, stub_servers: frozenset[str] | None = None) -> overlay_dir = resolve_overlay_dir(cfg_gw.overlay_dir) socket_path = Path(cfg_gw.socket_path) if cfg_gw.socket_path else default_socket_path() agents_source_dir = kiro_agents_dir() - workspace_default = _session_work_dir(None) + workspace_default = session_default_cwd(None) try: # rewrite_agents() walks ~/.kiro/agents, parses every JSON spec and diff --git a/test/chat_test_helpers.py b/test/chat_test_helpers.py index fab08d16642..4cd345d5c90 100644 --- a/test/chat_test_helpers.py +++ b/test/chat_test_helpers.py @@ -125,6 +125,12 @@ def _make_state(tmp_path, **kwargs): sessions.resumable_sid = MagicMock(return_value=None) sessions.remove = AsyncMock() sessions.discard_conversation = AsyncMock() + # Async: it resolves a cleared project off-thread, so a plain MagicMock hands the + # handler a non-awaitable and every workspace-switch request answers 500. + sessions.note_project_change = AsyncMock() + # Resolve-only helper: async, and it must hand back a real path string because the arm + # sites record `slot.project or `. + sessions.resolve_arm_cwd = AsyncMock(side_effect=lambda key, cwd: cwd or "/workspace/_default") sessions.aflush = AsyncMock() sessions.recycle_background = AsyncMock() sessions.get_pid = MagicMock(return_value=None) @@ -380,3 +386,15 @@ async def __anext__(self): item = self._items[self._index] self._index += 1 return item + + +def armed_cwd(boundary, folded: str) -> str | None: + """The directory this key's arm names, or ``None`` when it names none.""" + arm = boundary._arm_if_any(folded) + return arm.cwd if arm is not None else None + + +def armed_agent(boundary, folded: str) -> str | None: + """The agent this key's arm names, or ``None`` when it names none.""" + arm = boundary._arm_if_any(folded) + return arm.agent if arm is not None else None diff --git a/test/test_acp_session_provider.py b/test/test_acp_session_provider.py index da0566ae5b3..6c7ecb7f31a 100644 --- a/test/test_acp_session_provider.py +++ b/test/test_acp_session_provider.py @@ -2,6 +2,7 @@ from __future__ import annotations +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest @@ -470,6 +471,33 @@ def test_backend_reports_the_runtimes_backend(self): provider = AcpSessionProvider(handle, runtime) assert provider.backend == "kas" + def test_cwd_reports_the_sessions_bound_dir_not_the_shared_runtimes(self): + """A shared runtime carries sessions opened against different projects. + + The task runtime is started once in workspace A; a task session then opens against + B. Answering with the runtime's directory reports a workspace this session never + bound, so reuse validation reads it as moved and evicts a live session -- losing + its conversation for failing to be somewhere it never was. + """ + handle = _make_handle() + handle._bound_cwd = "/workspaces/b" + runtime = _make_runtime() + runtime._work_dir = Path("/workspaces/a") + provider = AcpSessionProvider(handle, runtime) + assert provider.cwd == "/workspaces/b", ( + "the session bound to B must report B; reporting the runtime's A evicts it " + f"on every project-scoped claim; got {provider.cwd!r}" + ) + + def test_cwd_falls_back_to_the_runtime_when_no_bound_dir_was_recorded(self): + """A handle predating the record is the single-session case, where they agree.""" + handle = _make_handle() + handle._bound_cwd = "" + runtime = _make_runtime() + runtime._work_dir = Path("/workspaces/a") + provider = AcpSessionProvider(handle, runtime) + assert provider.cwd == str(Path("/workspaces/a")) + def test_has_active_turn(self): """has_active_turn is a METHOD (parity with AcpClient) delegating to handle.is_turn_active. Callers invoke it with () -- a @property here diff --git a/test/test_channel.py b/test/test_channel.py index 3403e85e292..680d58a7b0b 100644 --- a/test/test_channel.py +++ b/test/test_channel.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + import pytest from kiro_crew.channel import ( @@ -189,6 +191,93 @@ async def test_thread_routing_to_parent_sender(self): await ch.post("human", "reply", from_role="Human", thread_id=msg.id) assert not orch.inbox.empty() + @pytest.mark.asyncio + async def test_a_threaded_reply_overlapping_a_clear_is_not_orphaned(self): + """A reply must never name a parent the clear removed while it waited. + + The clear-all holds `_log_lock` across its member-reset awaits and wipes both + `messages` and `_msg_index` before releasing. A send that resolves its thread + parent OUTSIDE that lock captures a parent id, blocks, and then appends a reply + pointing at a message the clear removed -- and it has already incremented + that discarded parent's reply_count. + """ + ch, orch, spec = self._make_channel_with_agents() + parent = await ch.post(orch.id, "initial", from_role="Orch") + + await ch._log_lock.acquire() + reply_task = asyncio.create_task( + ch.post("human", "reply", from_role="Human", thread_id=parent.id) + ) + for _ in range(5): + await asyncio.sleep(0) + ch.messages.clear() + ch._msg_index.clear() + ch._log_lock.release() + reply = await reply_task + + assert ( + reply.thread_id is None + ), "the reply names a thread parent the clear already wiped, so it is orphaned" + assert ( + reply.reply_to is None + ), "the reply routes to a parent sender resolved from a message that is gone" + + @pytest.mark.asyncio + async def test_a_delivered_message_is_never_absent_from_the_persisted_log(self): + """Delivery and persistence must not straddle the lock release. + + Releasing `_log_lock` after the append lets a clear wipe and persist before this + post delivers: the recipient then holds a message that no durable snapshot contains, + and the post's own `_save()` re-persists the wiped log over it. A delivery await has + to suspend for the window to open, which a default unbounded inbox does not do, so + the stub below makes that existing suspension point deterministic. + """ + + class _YieldingInbox: + """An inbox whose put suspends, as a bounded or contended one does.""" + + def __init__(self) -> None: + self.items: list = [] + + async def put(self, item) -> None: + await asyncio.sleep(0) + self.items.append(item) + + ch, orch, spec = self._make_channel_with_agents() + spec.inbox = _YieldingInbox() # type: ignore[assignment] + saves: list[list[str]] = [] + ch._save_fn = lambda c: saves.append([m.id for m in c.messages]) + + async def _clear_all() -> None: + async with ch._log_lock: + ch.messages.clear() + ch._msg_index.clear() + ch._save() + + # Both queue behind a lock this test holds, so the clear is already waiting when the + # post releases it -- the ordering a concurrent clear reaches on its own. + await ch._log_lock.acquire() + post_task = asyncio.create_task( + ch.post(orch.id, "hello", from_role="Orch", mention=spec.id) + ) + for _ in range(3): + await asyncio.sleep(0) + clear_task = asyncio.create_task(_clear_all()) + for _ in range(3): + await asyncio.sleep(0) + ch._log_lock.release() + + msg = await post_task + await clear_task + + assert msg.id in [ + m.id for m in spec.inbox.items + ], "the message was never delivered, so this test is not exercising the window" + assert any(msg.id in snap for snap in saves), ( + "the message was delivered to a member but no persisted snapshot ever contained " + f"it, so it is lost on restart; snapshots={saves}" + ) + @pytest.mark.asyncio async def test_human_message_resets_exchange_counts(self): ch, orch, spec = self._make_channel_with_agents() diff --git a/test/test_channel_clear_context_idle_member.py b/test/test_channel_clear_context_idle_member.py new file mode 100644 index 00000000000..36ccee24634 --- /dev/null +++ b/test/test_channel_clear_context_idle_member.py @@ -0,0 +1,431 @@ +"""A live channel member must not refuse clear-context for its whole lifetime. + +`run_channel_agent` takes the session lease once at spawn and releases it only when the +member dies, so a busy probe that reads the LEASE answers "busy" for as long as the member +exists. The contract this PR ships says a 409 is retryable once the named roles finish, and +the banner tells the user to retry when idle -- so a lease-scoped probe makes that retry +unsatisfiable on any channel with live members, idle ones included. + +These drive the REAL `discard_conversation`, not a stub: a stubbed teardown returns whatever +the test says and so cannot observe the predicate at all. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from chat_test_helpers import armed_agent, armed_cwd + +from kiro_crew.config import KiroCrewConfig +from kiro_crew.session import SessionManager + + +def _provider_factory(*, active_turn: bool): + def factory(session_key=None, agent=None, channel_id=None, cwd=None, **kwargs): + provider = MagicMock() + provider.start = MagicMock(return_value=asyncio.sleep(0)) + provider.shutdown = MagicMock(return_value=asyncio.sleep(0)) + provider.cwd = cwd or "/unset" + provider.context_usage_pct = MagicMock(return_value=0.0) + provider.is_alive = MagicMock(return_value=True) + provider.is_process_alive = MagicMock(return_value=True) + provider.has_active_turn = MagicMock(return_value=active_turn) + provider.runtime_info = MagicMock(return_value=(None, None)) + return provider + + return factory + + +async def _member_holding_its_lifetime_lease(mgr: SessionManager, key: str) -> None: + """Reproduce what `run_channel_agent` does: acquire once, mark, never release.""" + await mgr.get_or_create(key) + marked = mgr.mark_lifecycle_lease(key) + assert marked, "precondition: no session was registered, so nothing carried the lease" + + +class TestAnIdleListeningMemberDoesNotRefuseForever: + @pytest.mark.asyncio + async def test_clear_context_clears_while_a_member_holds_its_lifecycle_lease(self): + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:analyst" + await _member_holding_its_lifetime_lease(mgr, key) + + boundary = mgr._allocation_boundary() + folded = mgr._fold_key(key) + assert boundary._sessions[ + folded + ].semaphore.locked(), ( + "precondition: the member is not holding the lease, so this proves nothing" + ) + + torn_down = await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + assert torn_down, ( + "an IDLE listening member refused the clear, so the 409 the banner tells the user " + "to retry when idle can never succeed on any channel with live members" + ) + + @pytest.mark.asyncio + async def test_a_deferred_project_reset_lands_while_a_member_holds_its_lease(self): + """A project change must not be deferred forever by a lease that never releases. + + The deferred reset is a USER's project change and it re-arms a retry, so it must ask + the same narrow question `discard_conversation` asks. Against the strict predicate a + lifetime lease reads busy for the member's whole life: every retry declines, the flag + stays armed, and the live session keeps serving the directory the user left. + """ + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:scribe" + await _member_holding_its_lifetime_lease(mgr, key) + + boundary = mgr._allocation_boundary() + folded = mgr._fold_key(key) + assert boundary._sessions[ + folded + ].semaphore.locked(), ( + "precondition: the member is not holding the lease, so this proves nothing" + ) + assert boundary._sessions[ + folded + ].lifecycle_lease, ( + "precondition: the lease was not declared, so the narrow question cannot apply" + ) + + reset_ok = await mgr.reset(key, skip_if_busy=True, refuse_only_on_active_turn=True) + assert reset_ok, ( + "the deferred project-change reset was refused on the LEASE alone, so it re-arms " + "forever and every later turn runs in the project the user already left" + ) + + @pytest.mark.asyncio + async def test_a_deferred_project_reset_still_defers_for_a_declared_turn(self): + """The sibling case must keep working: a real in-flight reply is still refused.""" + mgr = SessionManager(KiroCrewConfig(), provider_factory=_provider_factory(active_turn=True)) + key = "channel:ch-ops:editor" + await _member_holding_its_lifetime_lease(mgr, key) + + reset_ok = await mgr.reset(key, skip_if_busy=True, refuse_only_on_active_turn=True) + assert not reset_ok, ( + "a member streaming a reply had its session torn down mid-turn, which is the loss " + "the deferral exists to prevent" + ) + + @pytest.mark.asyncio + async def test_a_member_with_a_reply_in_flight_still_refuses(self): + mgr = SessionManager(KiroCrewConfig(), provider_factory=_provider_factory(active_turn=True)) + key = "channel:ch-ops:researcher" + await _member_holding_its_lifetime_lease(mgr, key) + + torn_down = await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + assert not torn_down, ( + "the teardown ran while the member had a reply in flight, which destroys the " + "streaming turn the refusal exists to protect" + ) + + @pytest.mark.asyncio + async def test_a_plain_turn_lease_still_refuses_without_asking_the_provider(self): + # The dashboard holder takes the lease for ONE turn, and may hold it before any prompt + # is in flight, which `has_active_turn` cannot see. That holder must stay strict. + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "dashboard:chat-1" + await mgr.get_or_create(key) + + torn_down = await mgr.discard_conversation(key, skip_if_busy=True) + assert not torn_down, ( + "a turn-scoped lease was treated as idle, so a teardown can land on a turn that " + "has acquired but not yet put a prompt in flight" + ) + + +class TestAClearDuringPreStreamSetupDoesNotDestroyTheSession: + """A member's turn begins when it DEQUEUES, not when the provider registers a prompt. + + Between dequeue and the prompt going out the member builds its context, and the provider + reports no active turn for that whole window. A clear arriving there tears the session + down mid-setup, so the message is dropped with a visible error. + """ + + @pytest.mark.asyncio + async def test_a_declared_turn_refuses_the_clear_before_the_prompt_is_in_flight(self): + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:dev" + await _member_holding_its_lifetime_lease(mgr, key) + + # The pre-stream window: dequeued and working, but the provider still reports idle. + assert mgr.set_lifecycle_turn_active(key, True), "precondition: nothing was registered" + provider = mgr._allocation_boundary()._sessions[mgr._fold_key(key)].provider + assert ( + provider.has_active_turn() is False + ), "precondition: the provider already reports a turn, so this is not the window" + + cleared = await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + assert not cleared, ( + "the clear tore the session down while the member was mid-setup, so its message " + "is dropped with an error instead of being served" + ) + assert ( + provider.shutdown.call_count == 0 + ), "the provider was shut down during the pre-stream window" + + @pytest.mark.asyncio + async def test_the_clear_succeeds_once_the_turn_is_released(self): + # The refusal must be the retryable kind the banner promises, not a new permanent one. + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:dev" + await _member_holding_its_lifetime_lease(mgr, key) + mgr.set_lifecycle_turn_active(key, True) + mgr.set_lifecycle_turn_active(key, False) + + cleared = await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + assert cleared, ( + "a released turn still refused the clear, so the retry-when-idle the banner " + "offers can never succeed" + ) + + +class TestAReacquiredSessionKeepsItsTurnProtection: + """A reacquire happens MID-TURN, so the fresh session must carry the turn as well. + + Both recovery paths mint a new session whose turn flag defaults False. Marking only the + lease leaves the setup that follows unprotected, so a clear arriving there tears down the + provider the replayed message is about to stream. + """ + + @pytest.mark.asyncio + async def test_a_clear_cannot_tear_down_a_session_reacquired_mid_turn(self): + import kiro_crew.channel as channel_mod + + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:dev" + agent = SimpleNamespace( + session_key=key, + agent_name=None, + approval_policy=SimpleNamespace(value=""), + ) + + client = await channel_mod._reacquire_cleared_session(mgr, agent) + assert client is not None, "precondition: the reacquire failed" + + session = mgr._allocation_boundary()._sessions[mgr._fold_key(key)] + assert session.lifecycle_lease, "precondition: the lease was not declared" + + cleared = await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + assert not cleared, ( + "a clear tore down a session reacquired mid-turn, so the message being replayed " + "streams a provider that is already gone" + ) + + +class TestAnAgentOnlyArmDoesNotForceAColdStartEveryTurn: + """An arm that states no directory states no directory REQUIREMENT. + + A cwd-less claim -- which is what a channel turn is -- cannot state agreement about a + directory, so its binding has to settle the question. When the arm carries no directory + there is nothing to settle, and demanding one refuses every such claim forever: the + session is retired and cold started on each turn. + """ + + @pytest.mark.asyncio + async def test_a_cwdless_claim_reuses_the_session_under_an_agent_only_arm(self): + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:analyst" + boundary = mgr._allocation_boundary() + folded = mgr._fold_key(key) + + # An AGENT-only arm: no directory is stated, which is what `None` records. + mgr.mark_retire_on_next_claim(key, None, agent="kirocrew") + assert ( + armed_cwd(boundary, folded) is None + ), "precondition: a directory was armed, so this is not an agent-only arm" + assert armed_agent(boundary, folded) is not None, "precondition: no agent was armed" + + # The arm covers a session not yet registered, so the first claim registers one and + # leaves the arm standing. + first, _, _ = await mgr.get_or_create(key) + mgr.release(key) + + # The turn after: this claim states no directory, the arm states no directory + # requirement, and the agent matches -- so the arm is answered and SPENT. + second, _, _ = await mgr.get_or_create(key) + mgr.release(key) + assert armed_agent(boundary, folded) is None, ( + "the arm was never answered, so it stands over this key forever and every turn " + "pays a cold start" + ) + assert second is first, ( + "a cwd-less claim was refused its own session under an agent-only arm, which is a " + "cold start on every turn" + ) + + +class TestABackgroundSweepLeavesAListeningMemberAlone: + """A sweep must not tear down the provider a channel listener has cached. + + `run_channel_agent` fetches its provider ONCE and streams every later message through + that same object, with no re-fetch anywhere -- so a sweep that recycles the session + leaves the listener driving a dead provider, and its stream error is caught, reported + and swallowed, leaving the same dead object in place for every message after. + + The user-initiated clear reads the TURN, because the retry it offers has to be able to + succeed. A sweep reads the LEASE, because a live member is exactly what it must skip. + """ + + @pytest.mark.asyncio + async def test_an_idle_sweep_does_not_recycle_a_member_that_holds_its_lease(self): + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:scribe" + await _member_holding_its_lifetime_lease(mgr, key) + + boundary = mgr._allocation_boundary() + folded = mgr._fold_key(key) + cached_provider = boundary._sessions[folded].provider + + # What every skip_if_busy SWEEP does -- idle cleanup and compaction alike. + recycled = await mgr.reset(key, skip_if_busy=True) + assert not recycled, ( + "a background sweep recycled a listening member, so the provider its loop cached " + "is dead and every later message drives a dead session with nothing to re-fetch it" + ) + + # The subsequent message: the listener still holds a registered, live provider. + still_registered = boundary._sessions.get(folded) + assert still_registered is not None, "the sweep removed the member's session entirely" + assert still_registered.provider is cached_provider, ( + "the provider the listener cached was replaced, so its next message streams " + "through an object no longer registered for the key" + ) + assert cached_provider.shutdown.call_count == 0, ( + "the sweep shut the cached provider down, which is the teardown the listener " + "cannot observe and cannot recover from" + ) + + @pytest.mark.asyncio + async def test_the_user_initiated_clear_still_clears_the_same_member(self): + # The two callers must not collapse back into one answer: the sweep above is refused + # while this clear, on an identical idle member, still succeeds. + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = "channel:ch-ops:scribe" + await _member_holding_its_lifetime_lease(mgr, key) + + cleared = await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + assert cleared, ( + "exempting sweeps also blocked the user's clear, so the retry-when-idle refusal " + "is unsatisfiable again" + ) + + @pytest.mark.asyncio + async def test_a_session_the_listener_acquires_still_answers_a_user_clear(self): + """The INVARIANT, stated as the outcome it protects rather than as a marker. + + A session held for a member's whole listening life must stay distinguishable from a + turn in flight, or the user's clear is refused for as long as the member exists. So + each acquire path is driven, its TURN released as a finished turn releases it, and the + clear must then succeed -- the lease is still held, and holding it alone may not refuse. + Naming no mechanism, a rewrite that keeps clears working passes however it does it, + and dismantling the arm store does not make this test wrong. + """ + import kiro_crew.channel as channel_mod + + for helper in ( + channel_mod._reacquire_cleared_session, + channel_mod._reset_busy_session, + ): + mgr = SessionManager( + KiroCrewConfig(), provider_factory=_provider_factory(active_turn=False) + ) + key = f"channel:ch-ops:{helper.__name__.strip('_')}" + agent = SimpleNamespace( + session_key=key, + agent_name=None, + approval_policy=SimpleNamespace(value=""), + ) + assert ( + await helper(mgr, agent) is not None + ), f"precondition: {helper.__name__} did not acquire, so this proves nothing" + + # Both helpers run MID-TURN, so each declares the turn: a clear is refused here + # by design. The lease outlives the turn, which is what the next step isolates. + assert not await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ), f"{helper.__name__} left its mid-turn session clearable" + + mgr.set_lifecycle_turn_active(key, False) + cleared = await mgr.discard_conversation( + key, skip_if_busy=True, refuse_only_on_active_turn=True + ) + assert cleared, ( + f"the session {helper.__name__} acquired refuses the user's clear with NO turn " + "running, so a member holding it can never be cleared" + ) + + def test_no_acquire_site_sits_outside_the_functions_these_controls_drive(self): + """Completeness only: a NEW acquire site must not appear without a control. + + Discovery is from source because an unexercised site is invisible at runtime, but the + assertion is a SUBSET rather than a source-text match: removing acquire sites (which + is what dismantling the arm store does) shrinks the left side and still passes, while + adding one in a function no control drives fails. + """ + import re + from pathlib import Path + + import kiro_crew.channel as channel_mod + + # Explicit encoding: the default is the PLATFORM codec, and this module carries + # non-ASCII, so a Windows runner decodes it as cp1252 and raises. + source = Path(channel_mod.__file__).read_text(encoding="utf-8").splitlines() + enclosing = re.compile(r"^(?:async )?def (\w+)") + + sites: dict[int, str] = {} + for i, line in enumerate(source): + if "sessions.get_or_create(" not in line: + continue + for j in range(i, -1, -1): + m = enclosing.match(source[j]) + if m: + sites[i + 1] = m.group(1) + break + assert sites, "precondition: the acquire spelling changed, so this reads nothing" + + driven = { + "run_channel_agent", + "_reacquire_cleared_session", + "_reset_busy_session", + } + strays = {line: fn for line, fn in sites.items() if fn not in driven} + assert not strays, ( + "these acquire sites are in functions no control here drives, so nothing checks " + f"that a clear still works against the session they hold: {strays}" + ) diff --git a/test/test_channel_orphan_thread.py b/test/test_channel_orphan_thread.py new file mode 100644 index 00000000000..87bb801c259 --- /dev/null +++ b/test/test_channel_orphan_thread.py @@ -0,0 +1,73 @@ +"""A reply whose parent is gone posts top-level, and its thread pointer is cleared with it. + +Resolution shares the append's lock because an all-scope clear wipes the message index under that +same lock, so a parent present when the caller read it can be gone by the time the append runs. +``reply_to`` is set only when the parent is found, so RETAINING the thread id there would store a +pointer at a message no reader can resolve alongside an empty ``reply_to`` -- a pair that +disagrees with itself. Clearing both keeps them consistent and keeps the message, which is the +only outcome that loses neither the content nor the reader's ability to place it. +""" + +from __future__ import annotations + +import pytest + +from kiro_crew.channel import Channel + + +@pytest.mark.asyncio +async def test_a_reply_to_a_live_parent_keeps_its_thread_pointer(): + """The positive control: with the parent present, both halves of the pair are set.""" + ch = Channel(id="c1", topic="review") + await ch.post("alice", "the parent", from_role="alice") + parent = ch.messages[-1] + + await ch.post("bob", "the reply", from_role="bob", thread_id=parent.id) + reply = ch.messages[-1] + + assert reply.thread_id == parent.id + assert reply.reply_to == "alice", "the reply must name whom it answers" + assert parent.reply_count == 1, "the parent's reply count must move" + + +@pytest.mark.asyncio +async def test_a_reply_to_a_vanished_parent_posts_top_level(): + """The declared behaviour: the message survives, and neither half of the pair is left set.""" + ch = Channel(id="c1", topic="review") + await ch.post("alice", "the parent", from_role="alice") + gone_id = ch.messages[-1].id + + # Exactly what an all-scope clear does to the index this resolution reads. + ch.messages.clear() + ch._msg_index.clear() + + await ch.post("bob", "the reply", from_role="bob", thread_id=gone_id) + + assert len(ch.messages) == 1, "the message was dropped rather than posted top-level" + orphan = ch.messages[-1] + assert orphan.content == "the reply" + assert orphan.thread_id is None, ( + "the thread pointer survived its parent, so a reader resolves it to nothing while " + f"reply_to says there is no parent; got {orphan.thread_id!r}" + ) + assert ( + orphan.reply_to is None + ), "reply_to is set for a parent that does not exist, so the pair disagrees with itself" + + +@pytest.mark.asyncio +async def test_the_pair_is_never_half_set(): + """Whatever happens to the parent, the two fields agree: both set, or neither.""" + ch = Channel(id="c1", topic="review") + await ch.post("alice", "one", from_role="alice") + live = ch.messages[-1].id + await ch.post("bob", "two", from_role="bob", thread_id=live) + ch._msg_index.pop(live) + await ch.post("carol", "three", from_role="carol", thread_id=live) + await ch.post("dave", "four", from_role="dave") + + for msg in ch.messages: + assert (msg.thread_id is None) == (msg.reply_to is None), ( + f"half-set thread pointer on {msg.content!r}: " + f"thread_id={msg.thread_id!r} reply_to={msg.reply_to!r}" + ) diff --git a/test/test_channel_prompt_busy.py b/test/test_channel_prompt_busy.py index a70aea0522d..7a258b642fe 100644 --- a/test/test_channel_prompt_busy.py +++ b/test/test_channel_prompt_busy.py @@ -30,6 +30,7 @@ from kiro_crew.acp.client import AcpError, AcpPromptBusy from kiro_crew.channel import ( Channel, + _reacquire_cleared_session, _recover_busy_agent, _reset_busy_session, _stream_task, @@ -102,6 +103,8 @@ def __init__( self.resets: list[tuple[str, Any]] = [] self.acquires: list[str] = [] self.released: list[str] = [] + self.lifecycle_marked: list[str] = [] + self.turn_active_calls: list[tuple[str, bool]] = [] self._reset_exc = reset_exc # Number of resets that succeed before ``reset_exc`` starts firing. # 0 (the default) means the very first reset raises; 1 lets the swap @@ -123,6 +126,37 @@ async def reset(self, key, *, expect_session=None, **kwargs): def release(self, key, **kwargs): self.released.append(key) + def has_session(self, key): + return key in self._sessions + + def get_provider(self, key): + """The provider registered under this key, or ``None``. + + Distinct from ``has_session`` on purpose: the channel member compares this against the + provider it cached at spawn, so a replacement registered under the same key must be + visible as a DIFFERENT object rather than merely as presence. + """ + entry = self._sessions.get(key) + return entry.provider if entry is not None else None + + def mark_lifecycle_lease(self, key): + """A lease this loop keeps for the member's life, as the real manager records it.""" + session = self._sessions.get(key) + if session is None: + return False + session.lifecycle_lease = True + self.lifecycle_marked.append(key) + return True + + def set_lifecycle_turn_active(self, key, active): + """Records the member's own turn window, as the real manager does.""" + session = self._sessions.get(key) + if session is None: + return False + session.lifecycle_turn_active = active + self.turn_active_calls.append((key, active)) + return True + # ── Detection ── @@ -403,3 +437,99 @@ async def test_the_stuck_card_still_lands_when_the_teardown_reset_fails(): assert agent.state == "failed" assert len([m for m in ch.messages if "could not be recovered" in m.content]) == 1 assert sessions.released == [agent.session_key] + + +# ── a cleared member is re-acquired rather than left on a dead provider ── + + +@pytest.mark.asyncio +async def test_a_cleared_member_serves_its_next_message(): + """Clearing an idle member must not strand its listener on the shut-down provider. + + `api_channel_clear_context` discards the member's session, which pops the registry + entry and shuts the provider down -- and that provider is the one this member cached + when it spawned. Without a re-acquire the member streams a dead object for every later + message, and only a restart recovers it. + """ + agent = _make_agent() + served: list[str] = [] + fresh = _text_client("served", seen=served) + sessions = FakeSessions([_text_client("first"), fresh]) + + # Spawn: the member takes its lease and caches the provider it was handed. + cached = await _reacquire_cleared_session(sessions, agent) + assert cached is not None, "precondition: the spawn acquire failed" + assert sessions.has_session(agent.session_key), "precondition: no session was registered" + + # The clear: exactly what `discard_conversation` leaves behind -- key popped. + sessions._sessions.pop(agent.session_key) + assert not sessions.has_session( + agent.session_key + ), "precondition: the clear did not pop the key, so nothing is being tested" + + # The next message: the member must obtain a live provider rather than reuse the dead one. + replacement = await _reacquire_cleared_session(sessions, agent) + assert replacement is not None, ( + "the member could not re-acquire after its context was cleared, so every later " + "message fails until it is restarted" + ) + assert replacement is not cached, ( + "the member kept the provider the clear shut down, so its next message streams a " + "dead session" + ) + assert ( + await _stream_task(agent, _make_channel(), replacement, "hi") is False + ), "the re-acquired provider did not serve the message" + assert served == ["hi"], f"the message never reached the fresh provider; saw {served!r}" + assert sessions.lifecycle_marked, ( + "the re-acquired lease was not declared lifecycle-scoped, so the next clear on this " + "member is refused for as long as it lives" + ) + + +@pytest.mark.asyncio +async def test_a_replacement_registered_under_the_same_key_is_not_streamed_through(): + """A dequeued message must not reach the provider this member cached at spawn once the + registry holds a DIFFERENT one under the same key. + + Presence cannot answer this. An idle clear pops the key and shuts the cached provider down; + a surfaced dashboard tab can then be linked to the same key and register its own provider, + at which point a presence probe says "a session exists" and is satisfied, while the + object the member still holds is the dead one. Streaming into it loses the message. + """ + ch = Channel(id="c1", topic="review") + agent = ch.add_agent(role="dev", agent_name="dev") + assert agent is not None + + stale_seen: list[str] = [] + fresh_seen: list[str] = [] + stale = _text_client("from the stale provider", seen=stale_seen) + fresh = _text_client("from the replacement", seen=fresh_seen) + sessions = FakeSessions([stale, fresh]) + + task = asyncio.create_task(run_channel_agent(agent, ch, sessions)) + try: + # Let the member spawn and cache `stale` under the key before anything is queued. + assert await _wait_for(lambda: sessions.acquires and agent.state == "listening") + + # Exactly the shape the finding names: the entry is REPLACED, not removed, so the key is + # still present and only the identity differs. + key = sessions.acquires[0] + replacement = _text_client("from the replacement", seen=fresh_seen) + sessions._sessions[key] = SimpleNamespace(provider=replacement) + assert sessions.has_session(key), "precondition: presence must still hold" + assert sessions.get_provider(key) is not stale, "precondition: identity must differ" + + await ch.post("human", "please review this") + assert await _wait_for( + lambda: any(m.from_id == agent.id for m in ch.messages) + ), "the member produced nothing at all" + finally: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + + assert not stale_seen, ( + "the message was streamed through the provider cached at spawn, which the registry has " + f"already replaced; saw {stale_seen!r}" + ) diff --git a/test/test_chat_fork_cleared_project.py b/test/test_chat_fork_cleared_project.py new file mode 100644 index 00000000000..b26df98e075 --- /dev/null +++ b/test/test_chat_fork_cleared_project.py @@ -0,0 +1,83 @@ +"""A fork must carry the cleared-project marker, not just the project. + +An empty ``project`` states two different things: never scoped, or explicitly cleared. Only +``project_cleared`` separates them, and only the cleared answer invalidates a warm pooled +child. A fork that copies the project alone therefore claims no directory, the pool is not +blocked, and the fork binds the very directory its parent cleared. +""" + +from __future__ import annotations + +import pytest + +from kiro_crew.config.paths import CWD_CLEARED +from kiro_crew.dashboard.state import _ChatSlot + + +class TestForkingKeepsTheClearedProjectState: + def test_a_fork_of_a_cleared_slot_still_claims_cleared(self): + parent = _ChatSlot("chat-1") + parent.project = "/workspace/old" + assert parent.claim_cwd == "/workspace/old", "precondition: the parent was not scoped" + + # The user clears the project: the directory goes, the MARKER is what remains. + parent.project = "" + parent.project_cleared = True + assert parent.claim_cwd == CWD_CLEARED, "precondition: the clear did not register" + + fork = _ChatSlot("chat-2") + _copy_as_fork_does(parent, fork) + + assert fork.claim_cwd == CWD_CLEARED, ( + "the fork lost the cleared marker, so its claim states no directory -- which does " + "not invalidate a warm child, and the fork binds the directory the parent cleared; " + f"claim_cwd={fork.claim_cwd!r}" + ) + + def test_a_fork_of_a_never_scoped_slot_still_states_nothing(self): + """The marker must be COPIED, not asserted: an unscoped parent stays unscoped.""" + parent = _ChatSlot("chat-1") + assert parent.claim_cwd is None, "precondition: the parent was scoped" + + fork = _ChatSlot("chat-2") + _copy_as_fork_does(parent, fork) + + assert fork.claim_cwd is None, ( + "a fork of a slot that never had a project claims CLEARED, which discards the warm " + f"pool and its stored-cwd resume override for every fork; claim_cwd={fork.claim_cwd!r}" + ) + + def test_a_fork_of_a_scoped_slot_inherits_the_directory(self): + parent = _ChatSlot("chat-1") + parent.project = "/workspace/live" + + fork = _ChatSlot("chat-2") + _copy_as_fork_does(parent, fork) + + assert ( + fork.claim_cwd == "/workspace/live" + ), "the fork stopped inheriting its parent's working directory" + + +def _copy_as_fork_does(parent: _ChatSlot, fork: _ChatSlot) -> None: + """Mirror the fork endpoint's project-carrying lines, read from its own source. + + Driven off the SOURCE rather than hardcoded, so a copy line the endpoint adds or drops is + reflected here instead of this control silently testing a stale contract. + """ + import re + from pathlib import Path + + import kiro_crew.dashboard.chat_fork as fork_mod + + text = Path(fork_mod.__file__).read_text(encoding="utf-8") + copies = re.findall(r"^\s*new_slot\.(project(?:_cleared)?)\s*=\s*slot\.(\w+)\s*$", text, re.M) + assert copies, "precondition: the fork's project-copy lines were not found in source" + for attr, source_attr in copies: + setattr(fork, attr, getattr(parent, source_attr)) + + +@pytest.mark.parametrize("attr", ["project", "project_cleared"]) +def test_the_slot_carries_both_halves_of_the_project_answer(attr): + """Both must live in __slots__, or a fork cannot carry them at all.""" + assert attr in _ChatSlot.__slots__, f"{attr} left __slots__, so it cannot be set on a slot" diff --git a/test/test_chat_runner_coverage.py b/test/test_chat_runner_coverage.py index 010b57d2414..93ae1bb633d 100644 --- a/test/test_chat_runner_coverage.py +++ b/test/test_chat_runner_coverage.py @@ -28,13 +28,14 @@ import threading from contextlib import contextmanager from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest from chat_test_helpers import _make_ready_kiro_prerequisite from member_memory_helpers import patch_private_memory_supported from kiro_crew import name_grant +from kiro_crew.acp.client import AcpAuthRequired from kiro_crew.acp.types import ( EVENT_COMPLETE, EVENT_PERMISSION_REQUEST, @@ -78,6 +79,11 @@ def _state(tmp_path, **kwargs) -> DashboardState: # truthy, so every busy-probe would read "turn in flight" on an idle state. sessions.get_provider = MagicMock(return_value=None) sessions.resumable_sid = MagicMock(return_value=None) + # Disambiguates reset()'s overloaded False (busy-refused vs nothing live). + # True is the realistic default for these slots: a session exists. + sessions.has_session = MagicMock(return_value=True) + # Resolve-only helper: async, and must return a real path string for the arm sites. + sessions.resolve_arm_cwd = AsyncMock(side_effect=lambda key, cwd: cwd or "/w/_default") sessions.remove = AsyncMock() sessions.record_failure = AsyncMock() sessions.remove_if_unclaimed = AsyncMock(return_value=False) @@ -197,6 +203,11 @@ async def _drive(state, slot, message: str = "hello") -> None: await _settle(slot) +async def _noop_coro() -> None: + """An awaitable for a patched dispatch, so create_task gets a real coroutine.""" + return None + + async def _settle(slot) -> None: """Await (or cancel) any follow-up turn the finally block dispatched.""" task = slot.task @@ -1864,7 +1875,9 @@ async def test_successful_reset_clears_the_flag(self, tmp_path): await chat_runner._consume_pending_reset(state, slot, allow_discard=True) - state.sessions.reset.assert_awaited_once_with("dashboard:chat-cov-1", skip_if_busy=True) + state.sessions.reset.assert_awaited_once_with( + "dashboard:chat-cov-1", skip_if_busy=True, refuse_only_on_active_turn=True + ) assert slot._pending_reset_history_key is None @pytest.mark.asyncio @@ -1890,6 +1903,104 @@ async def test_busy_decline_leaves_the_flag_armed(self, tmp_path): assert slot.model_withheld is True await self._drain_retry(slot.key) + @pytest.mark.asyncio + async def test_every_persisted_project_cleared_read_demands_an_exact_true( + self, tmp_path, monkeypatch + ): + """`project_cleared` arrives from JSON, so truthiness is the wrong test for it. + + Every non-empty string is truthy, so a metadata line carrying the STRING "false" -- + which a hand-edited or foreign-writer file can hold -- binds the slot to the cleared + default instead of its own directory. The value has ONE legal true form. + + A census rather than one call: the finding is about the whole set of hydration sites, + and each one lives behind a loader signature that would have to be reconstructed to + reach. This fails on any site that reads the key without demanding exactly True, which + is the property the fix claims. + """ + from pathlib import Path + + root = Path(__file__).resolve().parents[1] / "src" / "kiro_crew" + unguarded: dict[str, int] = {} + for path in root.rglob("*.py"): + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if 'get("project_cleared")' in line and "is True" not in line: + unguarded[f"{path.name}:{n}"] = n + assert not unguarded, ( + "these reads of the persisted project_cleared flag test truthiness, so the string " + f'"false" reads as cleared and the slot binds the wrong directory: {unguarded}' + ) + + @pytest.mark.asyncio + async def test_a_denied_rebind_ends_the_episode_instead_of_retrying_it( + self, tmp_path, monkeypatch + ): + """A denial must not be re-pointed at the session it refused and retried. + + The rebind branch rewrites the pending flag to the key the slot settled on. Doing that + after a denial would aim the reset AT the non-owned session, and no retry could catch + it: `_settle_arm_target` runs the authorize gate only where the settled key DIFFERS + from the armed one, which a re-pointed flag does not. So the denial has to end + the episode. The arm itself stays owed on the originally-armed key -- this caller + holds no generation, so `_retract_own_arm` returns early and retracts nothing. + """ + monkeypatch.setattr(chat_runner, "_PENDING_RESET_RETRY_DELAY_SECS", 0.01) + state, slot = _state(tmp_path), _slot() + state._slots[slot.key] = slot + slot._app = "some-installed-app" + armed = f"dashboard:{slot.key}" + slot._pending_reset_history_key = armed + slot.linked_session_key = "cron:job-42" + state.sessions.reset = AsyncMock(return_value=True) + + await chat_runner._consume_pending_reset(state, slot) + + assert slot._pending_reset_history_key is None, ( + "the denied episode was left armed, so a retry re-runs it against the session the " + f"app does not own: {slot._pending_reset_history_key}" + ) + assert state.sessions.reset.call_args is None, ( + "a session the app does not own was reset after the rebind was DENIED: " + f"{state.sessions.reset.call_args}" + ) + + @pytest.mark.asyncio + async def test_an_app_owned_slot_cannot_transfer_its_arm_onto_a_linked_session( + self, tmp_path, monkeypatch + ): + """The deferred reset carries no request, so it must re-check ownership itself. + + Every other caller of the settle helpers passes `_app_cancel_denied`, re-running the + app check against the key the transfer lands on. This path arms inside a turn and + consumes later, resting on "authorized when armed" -- an assumption a rebind breaks: + a cron/workflow or channel link moves the arm onto a session the app has no claim on, + and the transfer then retires and re-roots it with nobody authorizing that. + """ + monkeypatch.setattr(chat_runner, "_PENDING_RESET_RETRY_DELAY_SECS", 0.01) + state, slot = _state(tmp_path), _slot() + state._slots[slot.key] = slot + slot._app = "some-installed-app" + slot._pending_reset_history_key = f"dashboard:{slot.key}" + # The rebind the surrounding comment calls routine: a cron link landing after arming. + slot.linked_session_key = "cron:job-42" + state.sessions.reset = AsyncMock(return_value=True) + + await chat_runner._consume_pending_reset(state, slot) + + moved = state.sessions.transfer_retire_arm.call_args + assert moved is None, ( + "an app-owned slot transferred its retirement arm onto a session it does not own, " + f"so the linked conversation is retired and re-rooted unauthenticated: {moved}" + ) + + @pytest.mark.asyncio + async def test_the_gate_lets_a_dashboard_owned_slot_settle_as_before(self, tmp_path): + """The positive control: an unscoped slot has no app scope, so nothing is refused.""" + state, slot = _state(tmp_path), _slot() + state._slots[slot.key] = slot + assert not getattr(slot, "_app", ""), "the fixture is app-owned; this proves nothing" + assert chat_runner._app_owned_rebind_denied(slot) is None + @pytest.mark.asyncio async def test_busy_decline_arms_a_retry_that_lands_the_reset(self, tmp_path, monkeypatch): # A channel-linked slot's turns never pass a dashboard turn boundary, @@ -1983,6 +2094,7 @@ async def test_a_rebind_re_arms_the_current_key_and_does_not_reset_the_stale_one state, slot = _state(tmp_path), _slot() state._slots[slot.key] = slot slot.linked_session_key = "slack:B.new" # slot now runs turns on B + slot.project_cleared = True # a resolved default is the CLEARED contract slot._pending_reset_history_key = "slack:A.old" # flag armed for A torn_down = await chat_runner._consume_pending_reset(state, slot, allow_discard=False) @@ -1991,6 +2103,12 @@ async def test_a_rebind_re_arms_the_current_key_and_does_not_reset_the_stale_one # A was never reset; the flag re-points at the slot's current session. state.sessions.reset.assert_not_awaited() assert slot._pending_reset_history_key == "slack:B.new" + # The arm MOVES rather than being duplicated, and records the RESOLVED default + # rather than the sentinel -- resolving at the arm site would block the gateway. + state.sessions.transfer_retire_arm.assert_called_once_with( + "slack:A.old", "slack:B.new", "/w/_default" + ) + state.sessions.mark_retire_on_next_claim.assert_not_called() await self._drain_retry(slot.key) @pytest.mark.asyncio @@ -2011,6 +2129,92 @@ async def test_attached_subagents_defer_the_pending_reset(self, tmp_path): assert slot._pending_reset_history_key == "dashboard:chat-cov-1" await self._drain_retry(slot.key) + @pytest.mark.asyncio + async def test_a_rebind_inside_the_reset_await_re_points_the_flag_to_the_new_key( + self, tmp_path + ): + """The reset await must not be the one unguarded gap in the settle. + + `_settle_and_transfer_arm` parks rebinds and transfers the arm, but its region CLOSES + when it returns, so the teardown await that follows ran outside any hold. A key bound + in that window carries no arm of its own: the arm it would need was already transferred + or spent on the key being torn down, and a later cwd-less claim would reuse the new + session with the directory the user just left. Holding the region across the await + parks that rebind, and the flag then re-points to it instead of clearing. + """ + from kiro_crew.dashboard.chat_utils import bind_linked_session_key + + state, slot = _state(tmp_path), _slot() + slot.linked_session_key = None + # NOT cleared: an unset project is answered synchronously, so the settle before the + # reset lands cleanly and the only await left to race is the teardown itself. + slot.project = "" + # The HISTORY key, which is what the settle resolves to: `slot.key` alone mismatches and + # takes the re-arm branch before the reset is ever reached. + slot._pending_reset_history_key = "dashboard:chat-cov-1" + + async def _rebind_during_reset(key, **kwargs): + # The ROUTED writer, which is the only kind production has: it parks while the + # region is open, so the key moves only once the hold unwinds. + bind_linked_session_key(slot, "cron:job-77") + return True + + state.sessions.reset = AsyncMock(side_effect=_rebind_during_reset) + state.sessions.has_session = MagicMock(return_value=True) + # The probe fails CLOSED, so a bare mock reads as "children attached" and defers before + # the reset is reached -- `_queued_depth` is the one that answers truthy by default. + state.subagents = MagicMock( + running_agents_for=MagicMock(return_value=[]), + _queued_depth=MagicMock(return_value=0), + ) + + await chat_runner._consume_pending_reset(state, slot, allow_discard=True) + + assert ( + state.sessions.reset.await_count == 1 + ), "precondition: the reset never ran, so the window under test was not entered" + assert ( + slot.linked_session_key == "cron:job-77" + ), "precondition: the rebind never landed, so this proves nothing" + assert slot._pending_reset_history_key == "cron:job-77", ( + "the flag cleared after a rebind landed inside the reset await, so the session now " + "bound to the slot carries no arm and its next cwd-less claim reuses the stale " + f"project; flag={slot._pending_reset_history_key!r}" + ) + await self._drain_retry(slot.key) + + @pytest.mark.asyncio + async def test_a_rebind_inside_the_resolve_parks_and_refuses_the_transfer(self, tmp_path): + """A rebind arriving inside the settle region PARKS, so nothing is armed on a spent key. + + Every writer of the linked key routes through `bind_linked_session_key`, which parks + while the region is open, so the key cannot move under the resolve. The parked rebind is + reported by `key_rebind_deferred`, and the settle refuses rather than transferring onto + a key the rebind is about to replace -- a refusal the retry then re-arms. + """ + from kiro_crew.dashboard.chat_utils import bind_linked_session_key + + state, slot = _state(tmp_path), _slot() + slot.linked_session_key = None + slot.project = "" + slot.project_cleared = True + slot._pending_reset_history_key = slot.key + + async def _rebind_then_resolve(key, cwd): + # The ROUTED writer, which is the only kind production has. + bind_linked_session_key(slot, "slack:C9:555") + return f"/resolved/for/{key}" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_rebind_then_resolve) + + await chat_runner._consume_pending_reset(state, slot, allow_discard=True) + + assert state.sessions.transfer_retire_arm.call_args is None, ( + "a rebind that parked inside the region still produced a transfer, so the arm " + f"lands on a key the rebind replaces: {state.sessions.transfer_retire_arm.call_args}" + ) + await self._drain_retry(slot.key) + @pytest.mark.asyncio async def test_a_key_queued_during_the_await_is_not_clobbered(self, tmp_path): state, slot = _state(tmp_path), _slot() @@ -2038,6 +2242,905 @@ async def test_reset_failure_leaves_the_flag_armed(self, tmp_path): assert slot._pending_reset_history_key == "old-key" + @pytest.mark.asyncio + async def test_the_project_reset_goes_through_the_atomic_skip_if_busy_path(self, tmp_path): + """Same invariant the discard already has: the busy check and the + teardown must be ONE step under the session lock. Probing here and + resetting afterwards leaves a window in which a channel turn acquires the + session's semaphore and begins streaming, and the teardown then removes + its provider. So the consumer must delegate the check rather than skip + it on the strength of where it is called from.""" + state, slot = _state(tmp_path), _slot() + slot.linked_session_key = "slack:C1:123" + slot._pending_reset_history_key = "slack:C1:123" + + await chat_runner._consume_pending_reset(state, slot) + + state.sessions.reset.assert_awaited_once_with( + "slack:C1:123", skip_if_busy=True, refuse_only_on_active_turn=True + ) + + @pytest.mark.asyncio + async def test_a_landed_project_reset_still_arms_the_key(self, tmp_path): + """A successful teardown removes ONE session, not every pre-change generation. + + More than one cold start can be in flight when the project changes: the reset in + front of them removes the registered one and reports success. Arming only on a + refusal therefore leaves the second free to register unguarded and serve the turn + from the superseded directory, so the arm is raised whatever the reset returns. + """ + state, slot = _state(tmp_path), _slot() + state.sessions.reset = AsyncMock(return_value=True) + slot.project = "/projects/beta" + slot.linked_session_key = "slack:C1:123" + slot._pending_reset_history_key = "slack:C1:123" + + torn_down = await chat_runner._consume_pending_reset(state, slot) + + assert torn_down is True, "precondition: the teardown has to actually land" + state.sessions.transfer_retire_arm.assert_called_once_with( + "slack:C1:123", "slack:C1:123", "/projects/beta" + ) + + @pytest.mark.asyncio + async def test_a_manager_refusal_leaves_the_project_reset_armed(self, tmp_path, monkeypatch): + """False with the session still live means it refused under the lock — a + turn was in flight. Held for the whole (bounded) wait, nothing was torn + down, so the flag must stay armed and land at a later consume rather + than let the next turn run against the pre-change session.""" + state, slot = _state(tmp_path), _slot() + state.sessions.reset = AsyncMock(return_value=False) + slot.linked_session_key = "slack:C1:123" + slot._pending_reset_history_key = "slack:C1:123" + + torn_down = await chat_runner._consume_pending_reset(state, slot) + + assert slot._pending_reset_history_key == "slack:C1:123" + assert torn_down is False + + @pytest.mark.asyncio + async def test_a_refused_project_reset_lands_at_a_later_boundary(self, tmp_path, monkeypatch): + """The refusal is a wait, not a cancellation.""" + state, slot = _state(tmp_path), _slot() + state.sessions.reset = AsyncMock(return_value=False) + slot.linked_session_key = "slack:A" + slot._pending_reset_history_key = "slack:A" + + await chat_runner._consume_pending_reset(state, slot) + assert slot._pending_reset_history_key == "slack:A" + + state.sessions.reset = AsyncMock(return_value=True) + torn_down = await chat_runner._consume_pending_reset(state, slot) + + assert slot._pending_reset_history_key is None + assert torn_down is True + + def test_no_claim_path_site_collapses_a_cleared_project_by_truthiness(self): + """`CWD_CLEARED` is `""`, so any `or` on the claim path can erase the requirement. + + The sibling census above pins the spellings that exist today. This one pins the + PATTERN, because the defect is a site nobody has written yet: `cwd=slot.project or + None` reads as ordinary defensiveness and silently turns "bind the default workspace" + back into "no requirement", which is the stale resume this module refuses. + + Scoped to the claim path deliberately. At the provider boundary the collapse is + CORRECT -- `session_allocation`'s `create_session(cwd=cwd or None)` hands a falsy + directory to a runtime that resolves its own default, so a tree-wide ban would + forbid the one site that must do it. + + One site is recorded rather than tolerated silently: routing it through + `_arm_cwd_for_claim` yields `str | None` where the transfer below it still declares + `cwd: str`, and widening that needs a write-protected module. A SECOND site fails. + """ + import re + from pathlib import Path + + from kiro_crew.dashboard import chat_handlers, chat_runner, chat_utils + from kiro_crew.dashboard.handlers import side + + # Any cwd taken from the slot must ask `claim_cwd` (or `_arm_cwd_for_claim`, which is + # its one wrapper), never re-derive it from `project` with a fallback. + derived = re.compile(r"cwd\s*=\s*slot\.project") + collapsed = re.compile(r"claim_cwd\s+or\b") + + # The one site still re-deriving it; see the docstring for why it cannot route yet. + known = {"chat_handlers": ["armed_cwd = slot.project or settled_cleared_cwd"]} + + for module in (chat_runner, chat_handlers, chat_utils, side): + src = Path(module.__file__).read_text(encoding="utf-8") + short = module.__name__.rsplit(".", 1)[-1] + found = [ + src[line_start:line_end].strip() + for line_start, line_end in ( + (src.rfind("\n", 0, m.start()) + 1, src.find("\n", m.end())) + for m in derived.finditer(src) + ) + ] + assert found == known.get(short, []), ( + f"{short} re-derives a claim cwd from slot.project instead of asking claim_cwd, " + f"so an unset project and a cleared one become the same value: {found}" + ) + hit = collapsed.search(src) + assert hit is None, ( + f"{short} collapses claim_cwd with `or`, which turns the cleared " + "sentinel back into 'no requirement' -- the stale resume this PR removes" + ) + + # Positive control: both patterns must be able to SEE the defect they forbid, or this + # census is a pair of regexes that can never match anything. + assert derived.search(" cwd=slot.project or None,\n") is not None + assert collapsed.search(" cwd=slot.claim_cwd or None,\n") is not None + + def test_a_cleared_project_is_stated_by_name_not_as_a_bare_sentinel(self): + """Pins the spelling where a cleared project is a REQUIREMENT, and only there. + + `cwd` carries two answers: `None` is "no requirement", and a cleared project is a + REQUIREMENT to bind the default workspace instead of the directory the session + already had. A bare `""` reads as the absence of a value -- the one thing it does + not mean -- so a reader deleting it as redundant, or an `or`-chain collapsing it, + silently restores the old project. + + Scoped to the sites that STATE a cwd. A blanket sweep for `slot.project or ""` + also catches labels -- `record_activity`'s `project=` is one -- and renaming those + imports allocation semantics they do not have. + """ + from pathlib import Path + + from kiro_crew.dashboard import chat_runner + from kiro_crew.dashboard.handlers import side + + for module in (chat_runner, side): + src = Path(module.__file__).read_text(encoding="utf-8") + assert "CWD_CLEARED" in src or "claim_cwd" in src, ( + f"{module.__name__} states a cleared project but not by name; census is " + "vacuous or the constant was inlined back to a bare sentinel" + ) + assert 'cwd=slot.project or ""' not in src, ( + f"{module.__name__} states a cwd of cleared as a bare '' -- unreadable " + "as a requirement, and indistinguishable from an unset value" + ) + # A delegating site is only as pinned as the accessor it delegates to, so the accessor + # must name the constant too -- else the requirement is inlined away one level down. + from kiro_crew.dashboard import state as state_mod + + state_src = Path(state_mod.__file__).read_text(encoding="utf-8") + accessor = state_src.split("def claim_cwd", 1) + assert len(accessor) == 2, "claim_cwd is gone; the delegating sites are unpinned" + assert "CWD_CLEARED" in accessor[1].split("@property", 1)[0], ( + "claim_cwd no longer names the cleared project, so every site delegating to it " + "states a cleared cwd as a bare sentinel" + ) + # The retirement arm takes the cwd positionally, so it needs its own assertion. + runner_src = Path(chat_runner.__file__).read_text(encoding="utf-8") + for spelling in ( + 'mark_retire_on_next_claim(pending_key, slot.project or "")', + 'mark_retire_on_next_claim(current_key, slot.project or "")', + 'transfer_retire_arm(pending_key, current_key, slot.project or "")', + ): + assert spelling not in runner_src, ( + "the arm records the directory a successor must BIND, so a cleared " + f"project there is a requirement and must be spelled CWD_CLEARED: {spelling}" + ) + # Positive control: the guard is only worth anything if it can see the real call. + assert "transfer_retire_arm(" in runner_src, ( + "the consume site's arm call was renamed or removed, so the spellings above " + "name nothing and this guard has gone vacuous" + ) + + def test_no_provider_invents_its_own_no_cwd_fallback(self): + """The allocation gate resolves a stated-nothing claim to ONE directory. + + `resolved_cwd("")` answers with `default_workspace_dir()`, so a provider that + binds somewhere else when handed no directory disagrees with the gate on every + project-less claim and is evicted as moved once per turn. The gate cannot detect + that -- the binding it reads looks like a real directory -- so what keeps the two + sides honest is that there is exactly one fallback expression. + + Scoped to the PROVIDER surface plus the gate -- `acp/` and `session_allocation` -- + not to the whole package. An earlier revision swept every module and required the + cli, inventory_gauges, deploy/handlers and spec_builder/repository sites to convert + too; those resolve the same directory for their own reasons and take no part in the + comparison, so requiring them made this a package-wide style rule riding on a + correctness fix. The definition in `config/paths.py` is the one sanctioned site + WITHIN that surface. Do not re-widen it without a gate that actually reads them. + """ + from pathlib import Path + + from kiro_crew import session_allocation + from kiro_crew.acp import client, runtime + + for module in (runtime, client, session_allocation): + src = Path(module.__file__).read_text(encoding="utf-8") + # Either names the shared default or delegates to the shared resolver + # that does; only an inline rebuild is drift. + fallbacks = src.count("default_workspace_dir()") + src.count("resolved_cwd(") + assert fallbacks >= 1, ( + f"{module.__name__} resolves a no-cwd binding but names no shared " + "default; census is vacuous or a second fallback was introduced" + ) + + root = Path(session_allocation.__file__).parent + definition = root / "config" / "paths.py" + + def _rebuilds_inline(text: str) -> bool: + """Only CODE counts. Prose naming the expression rebuilds nothing, and a + module documenting what the default work_dir IS is not drift.""" + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("#") or stripped.startswith("*"): + continue + if 'config_dir() / "workspace"' in line: + return True + return False + + # The surface the gate's agreement depends on: the two providers that can start + # with no cwd, and the allocation module that compares their binding. + surface = sorted(root.glob("acp/*.py")) + [Path(session_allocation.__file__)] + offenders = [ + str(p.relative_to(root)) + for p in surface + if p != definition and _rebuilds_inline(p.read_text(encoding="utf-8")) + ] + # Vacuity guard: the definition itself must contain the expression, or the + # sweep is matching nothing and would pass however far the package drifted. + assert _rebuilds_inline(definition.read_text(encoding="utf-8")), ( + "census cannot find the expression even at its definition; the sweep is " + "vacuous rather than clean" + ) + # And the comment-skip must not have blinded it: a planted inline rebuild is seen. + assert _rebuilds_inline( + ' x = config_dir() / "workspace"\n' + ), "the comment-skip swallowed a real inline rebuild, so the census is blind" + assert not _rebuilds_inline( + '# a comment naming config_dir() / "workspace"\n' + ), "the census still reads prose as a rebuild" + assert not offenders, ( + "these rebuild the default workspace inline instead of calling " + f"default_workspace_dir(), so they drift apart from the gate: {offenders}" + ) + + def test_live_slot_project_producers_bump_the_generation(self): + """Pins the PRODUCER set, the half the consume-side census cannot see. + + The retry site lets a live arm outrank the caller's `cwd`, which is sound only while + every path that MOVES a live slot's project also records the directory it committed. + A new producer without one leaves an unsatisfied arm naming the old directory and + the retry lands there -- the stale-directory harm delivered by the guard itself. + + Scoped to `slot.project` deliberately, not `slot.workspace`: the session cwd is + `slot.claim_cwd` at both spawn sites -- the project when set, `CWD_CLEARED` for an + EXPLICITLY CLEARED project only, and nothing at all for a project never set -- so a + workspace-only change moves no directory and owes no arm. If a cwd ever derives from + the workspace instead, that assignment becomes a producer and belongs here. + + Pairing, not raw counts: an earlier version asserted the number of `slot.project =` + sites, so any unrelated assignment broke it and the fix was to bump a number -- + which pins nothing. This asserts that each PRODUCER (a project assignment reachable + with a live session) sits in a body that records, and that the exempt shapes stay + recognisable as exempt. + + Bounded deliberately: a body may record either directly (`note_project_change`) or + by deferring (`_pending_reset_history_key`, whose consumer arms with the committed + project -- chat_runner's `mark_retire_on_next_claim`). Source text cannot tell a + CONDITIONAL deferral from an unconditional one, so this does NOT detect losing one + of two mechanisms in a body that holds both -- the agent switch is exactly that + shape. What it does detect, and what it exists for, is a NEW producer recording + nothing at all. The runtime guards for the existing ones are + `test_a_recreated_slot_keeps_its_own_project` and the busy-channel switch test. + """ + from pathlib import Path + + from kiro_crew.dashboard import chat_handlers, session_directive_apply + + for module in (chat_handlers, session_directive_apply): + src = Path(module.__file__).read_text(encoding="utf-8") + lines = src.splitlines() + assigns = [i for i, ln in enumerate(lines) if "slot.project = " in ln] + assert assigns, f"{module.__name__}: census found no project assignments" + + def _exempt(i: int) -> bool: + """Is the assignment at *i* a FILL, or slot CONSTRUCTION/hydration? + + Two shapes cannot move a live slot's project, so neither owes an arm: + + * a FILL -- enclosed by a `not slot.project` guard, so the slot had no + project to move off. Found by walking OUT to the enclosing `def` and + testing each strictly-shallower line, because the guard can sit any + distance above the assignment. + * CONSTRUCTION or hydration -- the enclosing handler CREATES the slot + rather than looking one up, so no session of it can exist yet. That is + what separates the two classes mechanically: every producer starts from + `state._slots.get(...)`, every constructor from `get_or_create_slot(...)`. + """ + body = _enclosing_body(i) + if "get_or_create_slot(" in body or "_ChatSlot(" in body: + return True + indent = len(lines[i]) - len(lines[i].lstrip()) + for j in range(i - 1, -1, -1): + ln = lines[j] + if not ln.strip(): + continue + outer = len(ln) - len(ln.lstrip()) + if outer >= indent: + continue + if "not slot.project" in ln and ln.lstrip().startswith(("if ", "elif ")): + return True + if ln.lstrip().startswith(("def ", "async def ")): + return False + indent = outer + return False + + def _enclosing_body(i: int) -> str: + """The whole function containing line *i*. + + The recording call can sit any distance from the assignment -- the project + endpoint commits at its top and arms 26 lines later, past any window worth + hard-coding -- so the unit is the function, not a span of lines. + """ + start = 0 + for j in range(i, -1, -1): + if lines[j].startswith(("def ", "async def ")) or lines[j].lstrip().startswith( + ("def ", "async def ") + ): + start = j + break + indent = len(lines[start]) - len(lines[start].lstrip()) + end = len(lines) + for j in range(i + 1, len(lines)): + ln = lines[j] + if not ln.strip(): + continue + here = len(ln) - len(ln.lstrip()) + if here <= indent and ln.lstrip().startswith(("def ", "async def ", "class ")): + end = j + break + return "\n".join(lines[start:end]) + + for i in assigns: + if _exempt(i) or "slot._app = " in "\n".join(lines[max(0, i - 4) : i + 4]): + continue + body = _enclosing_body(i) + records = ( + "note_project_change(" in body + or "_pending_reset_history_key = " in body + or "mark_retire_on_next_claim(" in body + ) + assert records, ( + f"{module.__name__}:{i + 1} moves a live slot's project without " + "recording the directory it committed, so an arm naming the OLD one " + "decides the next claim's retry. Record it, or -- if this cannot run " + "with a live session -- make it recognisably a FILL or construction" + ) + + # The both-mechanisms-one-lost case the OR above cannot see: a commit-path record + # satisfies it while a ROLLBACK inside the same body records nothing. + reverts = [ + i + for i, ln in enumerate(lines) + if "slot.project = " in ln + and ln.split("slot.project = ", 1)[1].strip() + in ("pre_await_project", "old_project", "prior_project") + ] + for i in reverts: + indent = len(lines[i]) - len(lines[i].lstrip()) + recorded = False + for j in range(i + 1, len(lines)): + ln = lines[j] + if not ln.strip(): + continue + here = len(ln) - len(ln.lstrip()) + if here < indent and ln.lstrip().startswith(("def ", "async def ", "class ")): + break + if any( + m in ln + for m in ( + "note_project_change(", + "mark_retire_on_next_claim(", + "_pending_reset_history_key = ", + ) + ): + recorded = True + break + assert recorded, ( + f"{module.__name__}:{i + 1} REVERTS a live slot's project and records " + "nothing after it, so the arm keeps naming the directory this request " + "abandoned. A rollback is a producer too: re-point the arm at the " + "post-rollback binding" + ) + + def test_an_identity_change_uses_the_identity_producer(self): + """Pins the producer CHOICE, which prose alone was guarding. + + The two producers are not interchangeable and the difference is silent: both record + the committed directory, but only `mark_retire_on_next_claim` states the agent a + successor must run. Pick `note_project_change` at a site that changes IDENTITY and + nothing fails — the arm is raised, the directory matches, and the switched-away + agent serves the next turn. The spend/keep side already has a ratchet + (`test_only_slot_ending_teardowns_spend_the_retirement_arm`); this is its producer-side + equivalent. + + The rule: a handler that changes which AGENT a slot runs must use the identity + producer and must pass `agent=`. A handler that only moves the DIRECTORY may use + either, because the directory is fully expressed by the arm. + """ + from pathlib import Path + + from kiro_crew.dashboard import chat_handlers + + src = Path(chat_handlers.__file__).read_text(encoding="utf-8") + lines = src.splitlines() + + def _enclosing_def(i: int) -> str: + for j in range(i, -1, -1): + if lines[j].startswith(("def ", "async def ")): + return lines[j].split("(")[0].split()[-1] + return "" + + identity_handlers = { + i for i, ln in enumerate(lines) if "slot.agent = " in ln and "==" not in ln + } + assert identity_handlers, "census found no agent assignment; it is now vacuous" + + for i in sorted(identity_handlers): + name = _enclosing_def(i) + start = next(j for j in range(i, -1, -1) if lines[j].startswith(("def ", "async def "))) + end = next( + ( + j + for j in range(i + 1, len(lines)) + if lines[j].startswith(("def ", "async def ")) + ), + len(lines), + ) + body = "\n".join(lines[start:end]) + # Construction and hydration are exempt by shape (same discriminator the + # project census uses): a handler that CREATES the slot has none to mis-serve. + if "get_or_create_slot(" in body or "_ChatSlot(" in body: + continue + assert "mark_retire_on_next_claim(" in body, ( + f"{name} changes the slot's AGENT but does not raise an identity arm, so a " + "cold start or a directory-matching claim can serve the replaced agent" + ) + assert "agent=" in body.split("mark_retire_on_next_claim(", 1)[1][:400], ( + f"{name} raises the arm without stating `agent=`, so the registration guard " + "has nothing to check the new session's identity against" + ) + + def test_only_slot_ending_teardowns_spend_the_retirement_arm(self): + """Every session-ending path must DECLARE a side; an unclassified one fails here. + + The rule follows from the map, not from a per-path preference: the arm names a + directory a SUCCESSOR must bind, so it may only be dropped where no successor can + arrive. `destroy` deletes the session-map entry and `close_all` ends the process, + so both spend it. The keep-side paths preserve or re-create that entry, so the arm + is still owed -- and it doubles as the retry target for a start the cleanup evicts, + whose own frame carries only the pre-change directory. + + The census is DERIVED from the module rather than written out here, because a + hand-written list is satisfied by a new teardown path simply not being in it: the + earlier version of this test asserted only that four named keep-side paths call + nothing, so `reset`, `discard_conversation`, `reload_provider_factory` and + `drain_all_providers` -- all of which evict a session-map entry -- were never + classified at all (Design review). Now any method that evicts an entry or spends an + arm must appear in `sides` below, so adding a teardown without choosing a side + FAILS instead of passing silently. + """ + import re + from pathlib import Path + + from kiro_crew import session_lifecycle + + # The declared classification. A census member absent from this map fails below. + sides = { + "destroy": "spend", + "close_all": "spend", + "remove": "keep", + "remove_if_unclaimed": "keep", + "reset": "keep", + "discard_conversation": "keep", + "reload_provider_factory": "keep", + "drain_all_providers": "keep", + } + + lines = Path(session_lifecycle.__file__).read_text(encoding="utf-8").splitlines() + starts = [ + (i, m.group(1)) + for i, ln in enumerate(lines) + if (m := re.match(r" (?:async )?def (\w+)\(", ln)) + ] + evicts = re.compile(r"_sessions\.pop\(|_sessions\.clear\(") + spends = re.compile(r"spend_retire_arm\(|discard_all_retire_arms\(") + + bodies = {} + for idx, (i, name) in enumerate(starts): + end = starts[idx + 1][0] if idx + 1 < len(starts) else len(lines) + bodies[name] = "\n".join(lines[i:end]) + + census = {n: b for n, b in bodies.items() if evicts.search(b) or spends.search(b)} + # Vacuity guard: a rename that empties the census must fail loudly rather than + # turn this into a check that cannot detect anything. + assert len(census) >= 5, f"census collapsed to {sorted(census)}; it is now vacuous" + + unclassified = sorted(set(census) - set(sides)) + assert not unclassified, ( + f"these paths end a session but declare no side: {unclassified} -- add each to " + "`sides` as 'spend' (no successor can arrive) or 'keep' (a successor is still " + "owed the arm), because an unclassified path leaks or wrongly spends an arm and " + "a recreated slot on the same key then inherits the prior project" + ) + + for name, side in sides.items(): + body = bodies.get(name) + assert body is not None, f"`{name}` is classified but is absent" + if side == "spend": + assert spends.search(body), ( + f"`{name}` is declared spend-side but drops no arm; the session-map " + "entry it removes can never be cleared" + ) + else: + assert not spends.search(body), ( + f"`{name}` is declared keep-side but spends the arm; a successor is " + "still coming and spending here also destroys the retry target for a " + "start this cleanup evicts" + ) + + def test_chat_runner_pending_reset_sites_declare_their_hold_decision(self): + """Pins the consume/acquire pairing IN `chat_runner` -- and only there. + + Scope, stated plainly because the obvious wider name would be a lie: session + acquisition happens all over the product -- measured at this commit, 32 calls + through the session facade across 21 modules (slack, telegram, discord, + taskrunner, subagent_manager, workflows, apps) -- and this census covers the + TWO in `chat_runner`. The covered figure is the one stated exactly; the total + moves every time main lands or deletes a caller, so a precise total here + would rot. It is scoped to `chat_runner` because the pending-reset flag is a + `chat_runner` concept -- `_consume_pending_reset` is defined and called + nowhere else -- so those two are the only sites that HAVE a decision to + declare. Every other acquisition proceeds without consulting the flag. + + That gap is now closed elsewhere rather than here: the cwd match validated in + `session_allocation._reacquire_and_validate` covers every acquisition, since + they all pass through that claim. This census keeps the narrower `chat_runner` + line -- a new path that acquires without stating what happens to a held reset + breaks the build instead of silently deferring a project change forever. + + Deliberately a census and not a behavioural test: the defect is the OMISSION, + so the thing to detect is a site that does not exist yet. Same reason the + repo pins its teardown/verdict rule and this file pins its cron producers. + + THREE bare sites, not two: the third is `_arm_pending_reset_retry`'s own loop. + A channel-linked slot's turns never cross a dashboard turn boundary, so a + decline there would never be retried and the live channel session would keep + the old CWD indefinitely; that task re-consumes on a timer until the flag + clears. Its decision is the same as the other bare sites -- defer and leave the + flag armed -- so it belongs to the bare shape rather than being a third shape. + """ + from collections import Counter + from pathlib import Path + + lines = Path(chat_runner.__file__).read_text(encoding="utf-8").splitlines() + consume_lines = [ + i + for i, line in enumerate(lines) + if "_consume_pending_reset(" in line and "async def" not in line + ] + acquisitions = [i for i, line in enumerate(lines) if "get_or_create(" in line] + # Vacuity guard: a rename that empties either list must fail loudly here + # rather than turn the whole census into a check that cannot detect anything. + assert len(consume_lines) >= 3, "census found no consume sites; it is now vacuous" + assert len(acquisitions) >= 2, "census found no acquisitions; it is now vacuous" + + # The two shapes, each a DECISION about a queued reset: bare = defer and + # leave the flag armed for a later boundary, allow_discard = tear the + # conversation down as well. A third shape, or a different count, means a + # new site was added without stating which decision it makes. + declared = Counter( + { + "await _consume_pending_reset(state, slot)": 3, + "torn_down = await _consume_pending_reset(state, slot, allow_discard=True)": 1, + } + ) + found = Counter(lines[i].strip() for i in consume_lines) + assert found == declared, ( + "the pending-reset consume census changed. Deferring is safe at every " + "site because a refused reset PINS the busy session for retirement at " + "its next claim, so a site needs only to say whether it also discards " + "the conversation. Add the new site here together with its " + f"decision.\n declared: {dict(declared)}\n found: {dict(found)}" + ) + + for site in acquisitions: + above = [c for c in consume_lines if c < site] + assert above, ( + f"get_or_create at {Path(chat_runner.__file__).name}:{site + 1} has no " + "pending-reset consume above it, so it can reuse a live session still " + "bound to the pre-change project cwd" + ) + + @pytest.mark.asyncio + async def test_a_plain_deferral_does_not_warn_about_waiting( + self, tmp_path, monkeypatch, caplog + ): + """A deferral is not an incident, so it must not log at WARNING. + + Nothing is LOST when a queued reset defers: the flag stays armed for a + later boundary, and the busy session is pinned invalid for the next claim, + so nothing can be handed it in the meantime. WARNING severity would page a + reader for ordinary contention, so this path is observable at DEBUG.""" + state, slot = _state(tmp_path), _slot() + state.sessions.reset = AsyncMock(return_value=False) + slot.linked_session_key = "slack:C1:123" + slot._pending_reset_history_key = "slack:C1:123" + + with caplog.at_level("DEBUG", logger="kiro_crew.dashboard.chat_runner"): + await chat_runner._consume_pending_reset(state, slot) + + # Scoped to the logger under test: caplog's handler is root-level, so an unrelated + # warning from elsewhere in the process lands here and fails this on someone else. + warnings = [ + r + for r in caplog.records + if r.levelname == "WARNING" and r.name == "kiro_crew.dashboard.chat_runner" + ] + assert not warnings, f"plain deferral must not WARN, got: {[r.message for r in warnings]}" + assert any( + "pinned for retirement at next claim" in r.getMessage() for r in caplog.records + ), "the plain deferral should still be observable at DEBUG" + # Still deferred, not consumed. + assert slot._pending_reset_history_key == "slack:C1:123" + + @pytest.mark.asyncio + async def test_an_inflight_cold_start_is_not_mistaken_for_no_session(self, tmp_path): + """An unregistered cold start is not "nothing to protect against". + + A cold start holds no registry entry until it finishes, so during that window + BOTH probes miss it: `reset` finds nothing to tear down and `has_session` reads + False. The flag is then cleared as satisfied while a provider bound to the + PRE-change directory is still on its way, and every later turn writes its + relative paths into the old project. + + The key must therefore be armed on this shape too -- pinning a registered + object cannot cover it, there being no object yet to pin. Asserts the arm + happens on the SAME key, since arming another protects nothing. + """ + state, slot = _state(tmp_path), _slot() + # Both probes blind: the cold start has not registered yet. + state.sessions.reset = AsyncMock(return_value=False) + state.sessions.has_session = Mock(return_value=False) + pin = Mock(return_value=False) + state.sessions.transfer_retire_arm = pin + slot.project = "" + slot.linked_session_key = "slack:C1:123" + slot._pending_reset_history_key = "slack:C1:123" + + await chat_runner._consume_pending_reset(state, slot) + + assert pin.call_count == 1, ( + "a refused reset with nothing registered must STILL arm the key -- an " + "in-flight cold start is invisible to both probes, so clearing the flag " + "here leaves a provider bound to the old project reusable" + ) + assert ( + pin.call_args.args[1] == "slack:C1:123" + ), "the arm must name the key whose reset was refused" + # The flag is still cleared, because an armed flag holds the queue and there + # is no live session left to release it. + assert slot._pending_reset_history_key is None, ( + "with nothing registered the flag must still clear -- leaving it armed " + "parks queued prompts with nothing to release them" + ) + + @pytest.mark.asyncio + async def test_a_deferred_reset_pins_the_session_against_the_next_claim(self, tmp_path): + """The refusal must not leave the stale session reusable. + + Refusing the teardown keeps a streaming reply alive, but the reason for it + does not expire with the refusal. Without a pin, the requested directory is + the only thing between a later turn and the pre-change session -- and it + cannot carry a CLEARED project, because `cwd=slot.project or None` collapses + "no project" into `None`, which states no requirement and matches any + binding. A turn after the clear would then be handed the session still bound + to the OLD directory and write its relative paths there. + + Asserts the pin is taken for the SAME key the reset was refused for, since + pinning a different key would read as fixed while protecting nothing. + """ + state, slot = _state(tmp_path), _slot() + state.sessions.reset = AsyncMock(return_value=False) + pin = Mock(return_value=True) + state.sessions.transfer_retire_arm = pin + # The CLEARED case specifically: no project left to state as a requirement. + slot.project = "" + slot.linked_session_key = "slack:C1:123" + slot._pending_reset_history_key = "slack:C1:123" + + await chat_runner._consume_pending_reset(state, slot) + + assert pin.call_count == 1, ( + "a deferred reset must pin the busy session for retirement -- without it " + "a cleared project cannot be expressed as a cwd requirement and the next " + "claim reuses the session bound to the old directory" + ) + assert pin.call_args.args[1] == "slack:C1:123", ( + "the pin must name the key whose reset was refused; pinning another key " + "protects nothing while reading as fixed" + ) + + @pytest.mark.asyncio + async def test_a_held_reset_also_holds_the_queue_against_pending_synthesis( + self, tmp_path, monkeypatch + ): + """Synthesis is a SECOND drain site, and the hold must cover it too. + + Gating the end-of-turn drain is not enough: ``_finish_queue_cycle`` starts + ``_run_pending_synthesis`` when a note is armed, and that in turn calls + ``_start_next_queued_turn`` whenever the queue is non-empty. So with a held + reset plus armed synthesis, the prompt is dequeued THERE instead, reaches + the same refusal, and is lost — the hold defeated by the path it did not + cover. The note must also stay armed, since suppressing synthesis is a + deferral, not a cancellation. + + Probes BEHAVIOUR rather than the new parameter, so it fails on the pre-fix + source for the intended reason instead of a TypeError about a keyword that + could not exist yet.""" + state, slot = _state(tmp_path), _slot() + # Every `will_synthesize` condition satisfied, or the dispatch never + # happens and the queue survives for the WRONG reason — a control that + # cannot fail. The slot must be REGISTERED and subagents must be idle. + slot._pending_synthesis = True + slot._synthesis_inflight = False + slot._subagent_deliveries_inflight = 0 + state._slots[slot.key] = slot + state.subagents = MagicMock(running_agents_for=MagicMock(return_value=[])) + slot.queue_append("queued prompt") + + # The hold is published on the SLOT, not passed as an argument -- its only + # caller set `slot._queue_held` a few lines before calling, so the parameter + # was a second spelling of one fact and has been removed. + slot._queue_held = True + with patch.object( + chat_runner, "_run_pending_synthesis", new=MagicMock(return_value=_noop_coro()) + ) as spy: + chat_runner._finish_queue_cycle(state, slot) + await _settle(slot) + + assert spy.call_count == 0, ( + "a held reset must suppress synthesis; it drains the queue via " + "_start_next_queued_turn, so the queued prompt would be dequeued " + "behind the hold and lost" + ) + assert len(slot._queue) == 1, "the queued prompt must survive" + assert slot._pending_synthesis is True, "the note is deferred, not cancelled" + + @pytest.mark.asyncio + async def test_a_signed_out_cli_also_holds_the_queue_against_synthesis(self, tmp_path): + """The auth hold is the SIBLING of the reset hold at this drain site. + + The tail-drain guard treats both as one case — each means this turn proved + every queued prompt would fail identically — so synthesis, which drains the + queue through `_start_next_queued_turn` too, cannot honour one and not the + other. Without `or _auth_required` a signed-out CLI holds the tail drain and + then loses the same prompts through synthesis instead. + + Drives the REAL auth path and asserts on what `_run_chat` PASSES, since the + change under test is the call-site expression, not `_finish_queue_cycle`'s + already-tested suppression. Asserting `queue_held` true directly here + would pass on both sides of the fix and prove nothing.""" + state, client = _runner_state(tmp_path) + slot = _slot() + + async def _signed_out(*a, **kw): + raise AcpAuthRequired("kiro-cli is not logged in") + yield # pragma: no cover - generator shape only + + client.stream = _signed_out + slot.queue_append("queued prompt") + + seen = {} + + # Captures the flag AS SEEN BY the callee, off the slot rather than off a + # kwarg: the hold is published to `slot._queue_held` before this call, and + # reading it there is what `_finish_queue_cycle` now does. + def _capture(st, sl, **kw): + seen["queue_held"] = sl._queue_held + + with patch.object(chat_runner, "_finish_queue_cycle", side_effect=_capture): + await _drive(state, slot, "first prompt") + + assert seen.get("queue_held") is True, ( + "a signed-out CLI must suppress synthesis for the same reason it holds " + "the tail drain; otherwise the queued prompt is dequeued there and lost" + ) + assert len(slot._queue) == 1, "the queued prompt must survive" + + @pytest.mark.asyncio + async def test_a_raising_reset_consume_still_holds_the_queue(self, tmp_path): + """A reset that FAILED to apply must hold the queue, not drain it. + + The end-of-turn consume resolves a cleared project's arm through the workspace + root's `mkdir`, which raises on a read-only or unreachable root. That raise is + swallowed so it cannot strand the steer requeue below -- but the hold was computed + INSIDE the same guard, so the raise skipped it and the queue drained with the reset + still armed: the queued prompt runs against exactly the state the user asked to + discard, and the arm that would have refused the stale session is still pending. + + Asserts on the DRAIN CALL, not on the published flag: the flag reads true for other + hold reasons (a signed-out CLI latches it earlier), so a turn that failed for any + unrelated reason would satisfy a flag assertion on both sides of the fix. Only the + `allow_discard=True` caller raises, which is uniquely the end-of-turn one. + """ + state, client = _runner_state(tmp_path) + _set_stream(client, [_complete()]) + slot = _slot() + slot.project = "" + slot._pending_reset_history_key = "dashboard:chat-cov-1" + slot.queue_append("queued prompt") + + async def _root_unavailable(st, sl, *, allow_discard: bool = False): + if allow_discard: + raise OSError("workspace root unavailable") + return False + + drained: list[str] = [] + + async def _drain(st, sl): + drained.append(sl.key) + return False + + with ( + patch.object(chat_runner, "_consume_pending_reset", side_effect=_root_unavailable), + patch.object(chat_runner, "_start_next_queued_turn", side_effect=_drain), + ): + await _drive(state, slot, "first prompt") + + assert ( + slot._pending_reset_history_key is not None + ), "precondition: a failed consume must leave the reset ARMED" + assert drained == [], ( + "the reset did not apply, so the tail drain must not run -- draining it starts " + f"the queued prompt against the state the user asked to discard; drained {drained}" + ) + assert len(slot._queue) == 1, "the queued prompt must survive the failed reset" + + @pytest.mark.asyncio + async def test_synthesis_still_dispatches_when_nothing_is_held(self, tmp_path): + """Positive control for the suppression: with no hold the synthesis + dispatch must still happen, so the fix narrows one branch rather than + disabling the feature. Passes on BOTH sides of the fix by construction — + that is the point.""" + state, slot = _state(tmp_path), _slot() + slot._pending_synthesis = True + slot._synthesis_inflight = False + slot._subagent_deliveries_inflight = 0 + state._slots[slot.key] = slot + state.subagents = MagicMock(running_agents_for=MagicMock(return_value=[])) + slot.queue_append("queued prompt") + + with patch.object( + chat_runner, "_run_pending_synthesis", new=MagicMock(return_value=_noop_coro()) + ) as spy: + chat_runner._finish_queue_cycle(state, slot) + await _settle(slot) + + assert spy.call_count == 1, "synthesis must still dispatch when nothing is held" + + @pytest.mark.asyncio + async def test_no_live_session_clears_the_flag_instead_of_deferring(self, tmp_path): + """``reset`` returns ``session is not None``, so False also means there + was nothing to tear down — the state the flag asks for. Treating that as + a deferral would leave it armed and reset the session the eager spawn + just created, paying the cold start that path exists to hide. The + await-count pins that this benign case does NOT enter the busy wait.""" + state, slot = _state(tmp_path), _slot() + state.sessions.reset = AsyncMock(return_value=False) + state.sessions.has_session = MagicMock(return_value=False) + slot._pending_reset_history_key = "dashboard:chat-cov-1" + + await chat_runner._consume_pending_reset(state, slot) + + assert state.sessions.reset.await_count == 1 + assert slot._pending_reset_history_key is None + @pytest.mark.asyncio async def test_no_pending_discard_is_a_noop(self, tmp_path): state, slot = _state(tmp_path), _slot() @@ -2107,7 +3210,9 @@ async def test_both_deferrals_run_because_neither_subsumes_the_other(self, tmp_p await chat_runner._consume_pending_reset(state, slot, allow_discard=True) - state.sessions.reset.assert_awaited_once_with("dashboard:chat-cov-1", skip_if_busy=True) + state.sessions.reset.assert_awaited_once_with( + "dashboard:chat-cov-1", skip_if_busy=True, refuse_only_on_active_turn=True + ) state.sessions.discard_conversation.assert_awaited_once_with( "dashboard:chat-cov-1", replay=False, skip_if_busy=True ) @@ -2177,7 +3282,9 @@ async def test_the_default_caller_still_consumes_a_project_reset(self, tmp_path) torn_down = await chat_runner._consume_pending_reset(state, slot) - state.sessions.reset.assert_awaited_once_with("dashboard:chat-cov-1", skip_if_busy=True) + state.sessions.reset.assert_awaited_once_with( + "dashboard:chat-cov-1", skip_if_busy=True, refuse_only_on_active_turn=True + ) assert torn_down is True @pytest.mark.asyncio diff --git a/test/test_chat_slack.py b/test/test_chat_slack.py index 02ea2365cb1..9aa4a6bc734 100644 --- a/test/test_chat_slack.py +++ b/test/test_chat_slack.py @@ -217,7 +217,10 @@ async def test_link_then_unlink_real_session_map(self, tmp_path, monkeypatch): The slack endpoints only call the slack-link delegation methods (get/set/clear_slack_link, get_session_for_thread), all of which SessionMap implements directly — so a raw SessionMap stands in for - the SessionManager here. + the SessionManager across the endpoint calls below. It does NOT stand + in for the allocator surface, and slot creation uses that (it + supersedes any retirement arm left on a recycled key), so the slot is + created before the swap rather than after it. """ from unittest.mock import patch @@ -225,11 +228,11 @@ async def test_link_then_unlink_real_session_map(self, tmp_path, monkeypatch): monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) state = _make_state(tmp_path) + slot = state.get_or_create_slot("s1") # Swap the mocked sessions for a real SessionMap backed by tmp_path. with patch("kiro_crew.session_map.config_dir", return_value=tmp_path): state.sessions = SessionMap() - slot = state.get_or_create_slot("s1") slot.append("user", "hello") slot.drain() state.slack_client = MagicMock() diff --git a/test/test_chat_slot_key_settling.py b/test/test_chat_slot_key_settling.py new file mode 100644 index 00000000000..194b60945cc --- /dev/null +++ b/test/test_chat_slot_key_settling.py @@ -0,0 +1,257 @@ +"""A ``linked_session_key`` rebind may not move the key an arm is settling onto. + +The finding this covers: the settle resolves against a key, then the arm is transferred onto +it. A writer landing between the two leaves the arm owed to a binding nobody is on. Seven of +the eight writers are SYNCHRONOUS functions, so an asyncio lock is unavailable to them, and +one transfer already runs inside ``async with slot._lock`` where a second acquisition of a +non-reentrant lock never returns -- so the exclusion is a synchronous non-blocking guard that +parks a rebind and reports it, rather than a lock. +""" + +from __future__ import annotations + +import re +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from kiro_crew.dashboard.chat_utils import ( + bind_linked_session_key, + key_rebind_deferred, + settling_key, +) + + +def _slot(key: str = "dashboard:test", linked: str = ""): + slot = SimpleNamespace() + slot.key = key + slot.linked_session_key = linked + slot._key_settling = 0 + slot._key_deferred = None + return slot + + +class TestNoWriterCanMoveTheKeyWhileAnArmSettles: + """The INVARIANT, asserted through behaviour rather than the writers' source text. + + What must hold is that a rebind cannot move the key an arm is settling onto. WHICH + mechanism delivers that is not the invariant, so a TRIPWIRE on the slot catches any path + that reaches the key inside a settling region, whatever route it took. A tree with no + settling regions trips nothing and passes, so dismantling the arm store leaves this test + correct rather than needing it rewritten. + """ + + def test_a_write_inside_a_settling_region_never_reaches_the_key(self): + class Tripwire: + """A slot that records every write that reaches the key itself.""" + + def __init__(self): + self.key = "dashboard:test" + self._key_settling = 0 + self._key_deferred = None + self._reached: list[str] = [] + self._linked = "slack:1111.0001" + + @property + def linked_session_key(self): + return self._linked + + @linked_session_key.setter + def linked_session_key(self, value): + if self._key_settling: + self._reached.append(value) + self._linked = value + + slot = Tripwire() + with settling_key(slot): + bind_linked_session_key(slot, "cron:job-9") + assert slot._reached == [], ( + "a writer moved the key while an arm was settling onto it, so the arm lands " + f"on a binding nobody is on; reached={slot._reached}" + ) + assert slot.linked_session_key == "cron:job-9", "the parked write was lost, not delayed" + + def test_no_key_writer_sits_in_a_module_that_serializes_nothing(self): + """Completeness only: a NEW writer must not appear where nothing serializes it. + + Discovery is from source because an unexercised writer is invisible at runtime, but + the assertion is a SUBSET -- writers may DISAPPEAR freely, which is what dismantling + does, while one added in a module that reaches the key with no serialization in sight + fails. The serialization point is resolved from the live object, so moving or renaming + it does not read as a stray writer. + """ + import pathlib + import sys + + import kiro_crew + + root = pathlib.Path(kiro_crew.__file__).parent + assign = re.compile(r"\.linked_session_key\s*=(?!=)") + point = pathlib.Path(sys.modules[bind_linked_session_key.__module__].__file__).resolve() + + strays = [] + seen_point = False + for path in sorted(root.rglob("*.py")): + text = path.read_text(encoding="utf-8") + if not assign.search(text): + continue + serialized = "bind_linked_session_key" in text + for n, line in enumerate(text.splitlines(), 1): + if "self.linked_session_key" in line or not assign.search(line): + continue + if path.resolve() == point: + seen_point = True + continue + if serialized: + continue + strays.append(f"{path.relative_to(root).as_posix()}:{n}: {line.strip()}") + + assert seen_point, ( + "no write was found inside the serialization point, so this pattern no longer " + "matches the real thing and would report every module as clean" + ) + assert not strays, ( + "these modules reach linked_session_key with nothing serializing them, so a " + "rebind can move the key while an arm settles:\n " + "\n ".join(strays) + ) + + def test_the_slot_factory_binds_its_keyword_only_when_it_mints_the_slot(self, tmp_path): + """The keyword surface cannot rebind a live slot, and no census above can see it. + + ``get_or_create_slot(linked_session_key=...)`` sets the key without ever naming the + attribute, so the dotted-assignment census is structurally blind to it. What keeps it + safe is not the guard but the factory's own early return for an EXISTING slot, which + precedes the bind: the keyword therefore binds only the slot it mints, where no arm + can yet be settling. Moving that bind above the early return would turn this surface + into a rebind the census still reports as clean. + """ + from chat_test_helpers import _make_state + + state = _make_state(tmp_path) + slot = state.get_or_create_slot("slack:C123.456", linked_session_key="slack:C123.456") + assert slot.linked_session_key == "slack:C123.456", ( + "the keyword did not bind the slot it minted, so a channel-born tab surfaces " + "unbound and answers from a session no channel reads" + ) + + again = state.get_or_create_slot(slot.key, linked_session_key="cron:job-9") + assert again is slot + assert slot.linked_session_key == "slack:C123.456", ( + "the factory rebound a LIVE slot from its keyword, a surface no census here " + "matches, so it can move the key while an arm settles onto the one it left" + ) + + +class TestARebindInsideTheSettlingRegionIsParkedNotApplied: + """Held open, the guard must keep the key still and say a rebind arrived.""" + + def test_a_writer_inside_the_region_cannot_move_the_key(self): + slot = _slot(linked="slack:1111.0001") + + with settling_key(slot): + landed = bind_linked_session_key(slot, "cron:job-9") + assert not landed, "the writer reported the key landed inside the region" + assert slot.linked_session_key == "slack:1111.0001", ( + "the key moved while an arm was settling onto it, so the arm is published " + f"onto a binding nobody is on; key={slot.linked_session_key!r}" + ) + assert key_rebind_deferred(slot), ( + "the settle cannot see that a rebind arrived, so it publishes onto the key " + "it settled on even though that key is spent" + ) + + assert slot.linked_session_key == "cron:job-9", ( + "the parked rebind was LOST rather than delayed, so the slot never reaches the " + f"session its writer bound it to; key={slot.linked_session_key!r}" + ) + assert not key_rebind_deferred(slot), "the deferral outlived its region" + + def test_an_unrecognised_depth_assigns_rather_than_parking(self): + """Parking is the direction that loses a bind, so an unknown shape must not park. + + A test double reports a truthy value for any attribute asked of it. Read as a depth + that would park every bind on a slot that never unwinds a region, and the key is + lost rather than delayed. + """ + mock_slot = MagicMock() + assert bind_linked_session_key(mock_slot, "cron:job-9"), ( + "a bind on a slot whose settling depth is not an int was PARKED, so it is lost " + "on any slot that never unwinds a settling region" + ) + assert mock_slot.linked_session_key == "cron:job-9" + assert not key_rebind_deferred(mock_slot), ( + "an unrecognised parked value was read as a rebind, which refuses every settle " + "on such a slot and leaves its arms unpublished forever" + ) + + def test_a_writer_outside_any_region_applies_immediately(self): + slot = _slot(linked="slack:1111.0001") + assert bind_linked_session_key(slot, "cron:job-9") + assert slot.linked_session_key == "cron:job-9", ( + "an ordinary rebind was deferred with no settle in progress, which would strand " + "every cron and workflow injection" + ) + + def test_the_regions_nest_without_releasing_early(self): + slot = _slot(linked="slack:1111.0001") + with settling_key(slot): + with settling_key(slot): + bind_linked_session_key(slot, "cron:inner") + assert slot.linked_session_key == "slack:1111.0001", ( + "the inner region applied the parked key while the outer one was still " + "settling, so the outer arm publishes onto a spent key" + ) + assert slot.linked_session_key == "cron:inner" + + def test_the_guard_unwinds_on_an_exception(self): + slot = _slot(linked="slack:1111.0001") + with pytest.raises(RuntimeError): + with settling_key(slot): + bind_linked_session_key(slot, "cron:job-9") + raise RuntimeError("the settle failed") + assert ( + slot._key_settling == 0 + ), "the guard leaked, so every later rebind on this slot is parked forever" + assert slot.linked_session_key == "cron:job-9", "the parked key was lost on the error path" + + +class TestTheSettleReportsUnsettledWhenARebindWasParked: + """The settle must publish nothing when a rebind landed inside its own region.""" + + @pytest.mark.asyncio + async def test_a_rebind_during_the_resolve_leaves_the_arm_unpublished(self): + from kiro_crew.dashboard.chat_runner import _settle_arm_target + + slot = _slot(linked="slack:1111.0001") + state = MagicMock() + superseded: list = [] + transferred: list = [] + state.sessions.supersede_arm_for_new_slot = MagicMock( + side_effect=lambda *a, **k: superseded.append((a, k)) + ) + state.sessions.transfer_retire_arm = MagicMock(side_effect=lambda *a: transferred.append(a)) + + # The rebind lands DURING the resolve await, which is the window the finding names. + async def resolve(key, cleared): + bind_linked_session_key(slot, "cron:job-9") + return "/workspace/resolved" + + state.sessions.resolve_arm_cwd = resolve + + denied, key, cwd, settled = await _settle_arm_target( + state, slot, "slack:1111.0001", "", None + ) + + assert denied is None + assert not settled, ( + "the settle reported SETTLED with a rebind already parked, so its caller " + f"publishes an arm onto the spent key {key!r}" + ) + assert not superseded, ( + "the unsettled retract spent an arm this caller never wrote, so a later claim " + "stating no directory is served the superseded project with nothing to retire it" + ) + assert ( + slot.linked_session_key == "cron:job-9" + ), "the rebind was lost, so the slot never reaches the session it was bound to" diff --git a/test/test_chat_slot_project.py b/test/test_chat_slot_project.py index 36d2ccf6e50..6473d4ad34d 100644 --- a/test/test_chat_slot_project.py +++ b/test/test_chat_slot_project.py @@ -8,14 +8,28 @@ from aiohttp import web from aiohttp.test_utils import TestClient, TestServer -from kiro_crew.dashboard.chat import api_chat_slot_project +from kiro_crew.dashboard.chat import api_chat_slot_project, api_chat_slot_workspace from kiro_crew.dashboard.state import DashboardState, _ChatSlot +from kiro_crew.session_allocation import RetireArm + + +def _armed_cwd(boundary, folded: str) -> str | None: + """The directory an arm names, or ``None`` when nothing is armed for this key.""" + arm = boundary._arm_if_any(folded) + return arm.cwd if arm is not None else None + + +def _is_armed(boundary, folded: str) -> bool: + """True while this key still has an arm to honour, whatever its record's history.""" + arm = boundary._arm_if_any(folded) + return arm is not None and (arm.cwd is not None or arm.agent is not None) def _make_app(state: DashboardState) -> web.Application: app = web.Application() app["state"] = state app.router.add_post("/api/chat/slots/{slot}/project", api_chat_slot_project) + app.router.add_post("/api/chat/slots/{slot}/workspace", api_chat_slot_workspace) return app @@ -27,12 +41,92 @@ def _mock_state(slot: _ChatSlot | None = None) -> DashboardState: state.push_slots_update = MagicMock() state.sessions = MagicMock() state.sessions.reset = AsyncMock() + # Resolve-only helper: async, and must return a real path string for the arm sites. + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=lambda key, cwd: cwd or "/w/_default") state.file_indexes = MagicMock() state.file_indexes.acquire = AsyncMock() state.file_indexes.release = AsyncMock() return state +class TestWorkspaceSwitchAdvancesTheGeneration: + """A workspace switch commits a new project, so it must advance the generation. + + The retry site prefers a live arm over the cwd its caller stated, which is correct + while the arm is the newest statement about the project. A switch that commits a new + project without advancing the generation leaves the earlier arm live, so it outranks + the selection and the first turn's relative writes land in the project the user left. + """ + + @staticmethod + def _factory(seen: list): + def factory(session_key=None, agent=None, channel_id=None, cwd=None, **kwargs): + provider = AsyncMock() + provider.start = AsyncMock() + provider.shutdown = AsyncMock() + provider.cwd = cwd if cwd else "/unset" + provider.context_usage_pct = MagicMock(return_value=0.0) + provider.is_alive = MagicMock(return_value=True) + provider.is_process_alive = MagicMock(return_value=True) + provider.has_active_turn = MagicMock(return_value=False) + provider.runtime_info = MagicMock(return_value=(None, None)) + seen.append(provider) + return provider + + return factory + + @pytest.mark.asyncio + async def test_the_retried_turn_binds_the_newly_selected_workspace(self, tmp_path): + from kiro_crew.config import KiroCrewConfig + from kiro_crew.dashboard.chat_utils import effective_session_key + from kiro_crew.session import SessionManager + + beta = tmp_path / "beta" + gamma = tmp_path / "gamma" + for d in (beta, gamma): + d.mkdir() + + seen: list = [] + mgr = SessionManager(KiroCrewConfig(), provider_factory=self._factory(seen)) + slot = _ChatSlot("test") + slot.project = str(beta) + state = _mock_state(slot) + state.sessions = mgr + + # The key the slot's turns actually run on, which is what the handler advances. + session_key = effective_session_key(slot) + # The eager spawn for beta armed the key; nothing has satisfied it yet. + mgr.mark_retire_on_next_claim(session_key, str(beta)) + + with ( + patch( + "kiro_crew.dashboard.chat_handlers._reset_slot_session_or_warn", + new=AsyncMock(return_value=True), + ), + patch( + "kiro_crew.dashboard.chat_handlers.default_project_dir", + new=MagicMock(return_value=str(gamma)), + ), + patch("kiro_crew.dashboard.chat_handlers.save_slot_off_loop", new=AsyncMock()), + ): + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/workspace", json={"workspace": "gamma-ws"} + ) + assert resp.status == 200, await resp.text() + + assert slot.project == str(gamma), "precondition: the switch committed gamma" + + # The first turn after the switch. It states gamma; the retry site must not + # substitute the arm's beta for it. + provider, _, _ = await mgr.get_or_create(session_key, cwd=str(gamma)) + assert provider.cwd == str(gamma), ( + "the turn after a workspace switch must bind the selected project; binding " + f"{provider.cwd!r} writes into the workspace the switch left" + ) + await mgr.close_all() + + class TestChatSlotProject: @pytest.mark.asyncio async def test_set_project(self, tmp_path): @@ -63,6 +157,35 @@ async def test_clear_project(self, tmp_path): assert resp.status == 200 assert slot.project == "" + @pytest.mark.asyncio + async def test_a_failed_workspace_resolution_rolls_the_project_commit_back(self, tmp_path): + """An unresolvable default workspace must not leave the slot committed but unarmed. + + The commit lands before the arm target is resolved, so a raise there leaves + `slot.project` on the new value with NO deferred reset and NO retirement arm -- and a + later cwd-less channel claim then reuses the session still rooted at the OLD project. + The commit is rolled back on its own token identity and the caller is told, rather + than the change half-applying. + """ + slot = _ChatSlot("test") + slot.project = str(tmp_path) + state = _mock_state(slot) + state.sessions.resolve_arm_cwd = AsyncMock( + side_effect=OSError("default workspace unavailable") + ) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/project", json={"project": ""}) + assert ( + resp.status == 503 + ), f"a resolution failure must be reported, not swallowed; got {resp.status}" + assert slot.project == str(tmp_path), ( + "and the commit must be ROLLED BACK -- a slot left on the new project with no " + f"arm serves the old session to the next cwd-less claim; got {slot.project!r}" + ) + assert ( + slot._pending_reset_history_key is None + ), "nothing may be armed either, or the rollback and the arm disagree" + @pytest.mark.asyncio async def test_nonexistent_dir_returns_400(self): slot = _ChatSlot("test") @@ -164,6 +287,56 @@ async def test_change_defers_session_reset(self, tmp_path): # Flag is set on the slot so chat_runner can consume it at the turn boundary. assert slot._pending_reset_history_key == "dashboard:test" + @pytest.mark.asyncio + async def test_the_arm_is_raised_before_the_endpoint_returns(self, tmp_path): + """The arm cannot wait for the deferred consumer -- there is a window before it. + + The RESET is deferred because this endpoint is reachable over loopback from inside + the kiro-cli process group, so an inline teardown would killpg the caller. The ARM + is not: it is in-memory bookkeeping. Leaving it to `_consume_pending_reset` puts it + behind the eager task's 1.5s debounce, and a channel turn arriving in that window + states NO cwd -- so the claim-time directory check cannot fire and the arm is the + only thing that would refuse the pre-change session. Raised here, the window is + closed by the time the caller gets its 200. + """ + slot = _ChatSlot("test") + slot.linked_session_key = "slack:1234567890.123456" + state = _mock_state(slot) + with patch("kiro_crew.dashboard.chat_handlers._save_recent_project"): + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/project", + json={"project": str(tmp_path)}, + ) + assert resp.status == 200 + + # Nothing has consumed the deferral yet -- no eager task was allowed to run. + assert slot._pending_reset_history_key == "slack:1234567890.123456" + state.sessions.mark_retire_on_next_claim.assert_called_once_with( + "slack:1234567890.123456", str(tmp_path) + ) + + @pytest.mark.asyncio + async def test_channel_linked_slot_defers_reset_on_its_channel_session(self, tmp_path): + """A channel-born slot runs its turns on the channel's own session, so the + deferred teardown has to name THAT session. The ``dashboard:`` prefix is + unconditional, so deriving the key from the slot key instead would name a + nonexistent ``dashboard:slack:``, the teardown would miss the live + session, and a later turn would reuse it with the pre-change directory.""" + slot = _ChatSlot("test") + slot.linked_session_key = "slack:1234567890.123456" + state = _mock_state(slot) + with patch("kiro_crew.dashboard.chat_handlers._save_recent_project"): + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/project", + json={"project": str(tmp_path)}, + ) + assert resp.status == 200 + state.sessions.reset.assert_not_awaited() + assert slot._pending_reset_history_key == "slack:1234567890.123456" + assert "dashboard:" not in slot._pending_reset_history_key + @pytest.mark.asyncio async def test_unchanged_does_not_set_pending_reset(self, tmp_path): """No-op when project doesn't change: no inline reset and no flag set.""" @@ -220,3 +393,445 @@ def test_folder_overlap_check_accepts_non_overlapping_dir(self, tmp_path, monkey clean = tmp_path / "clean" clean.mkdir() assert _folder_project_overlap_denied(str(clean)) is None + + +class TestClaimCwdTriState: + """A slot with no project must be distinguishable from one whose project was cleared. + + Stating the cleared sentinel for both makes EVERY project-less slot -- the default + configuration -- bypass the warm pool and skip its stored-cwd resume override, while the + staleness the sentinel exists to refuse only arises once a binding has actually changed. + """ + + def test_a_slot_that_never_had_a_project_states_no_cwd(self): + slot = _ChatSlot("test") + assert slot.project == "" + assert slot.claim_cwd is None, ( + "a default-configuration slot states the cleared sentinel, so it loses the warm " + f"pool and its resume override; got {slot.claim_cwd!r}" + ) + + def test_a_cleared_project_still_states_the_cleared_sentinel(self): + from kiro_crew.config.paths import CWD_CLEARED + + slot = _ChatSlot("test") + slot.project_cleared = True + assert slot.claim_cwd == CWD_CLEARED + + def test_a_set_project_states_that_directory(self): + slot = _ChatSlot("test") + slot.project = "/workspace/thing" + slot.project_cleared = False + assert slot.claim_cwd == "/workspace/thing" + + def test_the_cleared_marker_beats_a_project_that_outlived_its_clear(self): + """A retained on-disk project must NOT resurrect the former working directory. + + The metadata merge is an upsert that cannot delete a key, so a slot whose project was + set and then cleared still carries the old directory in its record. Reading the project + first honored that value, and relative writes landed silently in the former project. + """ + from kiro_crew.config.paths import CWD_CLEARED + + slot = _ChatSlot("test") + # The shape persistence can hand back: a retained value beside the clear marker. + slot.project = "/workspace/old" + slot.project_cleared = True + + assert slot.claim_cwd == CWD_CLEARED, ( + "the slot states the former project, so its next claim binds a directory the user " + f"cleared and writes land there unannounced; claim_cwd={slot.claim_cwd!r}" + ) + + def test_a_cleared_project_survives_a_restart(self): + """The cleared state must round-trip through the persisted slot metadata. + + A cleared project writes no ``project`` key at all, so unless the flag is persisted in + its own right the restored slot is indistinguishable from one that never had a project + -- and the resume then restores the very directory the clear was meant to abandon. + + Asserted at the metadata contract rather than by booting a second process: every + persist site must emit the key and every restore site must read it, and a slot rebuilt + from that metadata must state the same cwd as the live one. + """ + import json + from pathlib import Path + + from kiro_crew.dashboard import chat_persistence + + src = Path(chat_persistence.__file__).read_text(encoding="utf-8") + writes = src.count('"project_cleared"] = bool(') + reads = src.count('meta.get("project_cleared")') + assert writes >= 2, ( + f"only {writes} persist site(s) emit the cleared flag; a slot saved by the other " + "path loses it, so the clear does not survive a restart" + ) + assert reads >= 2, ( + f"only {reads} restore site(s) read the cleared flag; a slot loaded by the other " + "path comes back looking as though it never had a project" + ) + + live = _ChatSlot("test") + live.project_cleared = True + meta: dict = {} + if live.project: + meta["project"] = live.project + if getattr(live, "project_cleared", False): + meta["project_cleared"] = True + + restored = _ChatSlot("test") + reloaded = json.loads(json.dumps(meta)) + if reloaded.get("project"): + restored.project = reloaded["project"] + if reloaded.get("project_cleared"): + restored.project_cleared = True + + assert restored.claim_cwd == live.claim_cwd, ( + "the restored slot states a different cwd than the live one, so the next claim " + f"binds elsewhere; live={live.claim_cwd!r} restored={restored.claim_cwd!r}" + ) + + +class TestArmRetractionIsScopedToItsOwnGeneration: + """A producer unwinding its own arm must not drop an arm another producer just wrote. + + `supersede_arm_for_new_slot` serves two callers. At slot mint and final teardown the slot + is gone, so every arm on its key is void whoever wrote it and the unconditional drop is + right. On a producer's DENIAL path it is instead a RETRACTION of that producer's own work, + and the awaits before it are real yield points -- so a second producer can have armed the + same key in between. Dropping that arm leaves its session reusable at the old cwd, which + is a silent cross-project write. + """ + + def test_a_retraction_leaves_a_concurrent_producers_arm_in_place(self, tmp_path): + from kiro_crew.config import KiroCrewConfig + from kiro_crew.session import SessionManager + + mgr = SessionManager(KiroCrewConfig()) + key = "chat-arm-1" + mine, theirs = tmp_path / "mine", tmp_path / "theirs" + mine.mkdir() + theirs.mkdir() + + # Producer A arms, then yields: the handler awaits a thread and a resolve here. + generation_a = mgr.mark_retire_on_next_claim(key, str(mine)) + assert isinstance( + generation_a, int + ), "the arm reports no generation, so a producer cannot name its own arm to retract" + + # Producer B arms the SAME key inside A's window, superseding A's arm. + mgr.mark_retire_on_next_claim(key, str(theirs)) + + # A is denied and unwinds, and must retract only what it wrote. + mgr.supersede_arm_for_new_slot(key, only_generation=generation_a) + + boundary = mgr._allocation_boundary() + folded = mgr._fold_key(key) + assert _armed_cwd(boundary, folded) == str(theirs), ( + "the denial dropped a concurrent producer's arm, so its session stays reusable " + f"at the old cwd; arm={_armed_cwd(boundary, folded)!r}" + ) + + def test_a_retraction_still_drops_the_arm_it_owns(self, tmp_path): + from kiro_crew.config import KiroCrewConfig + from kiro_crew.session import SessionManager + + mgr = SessionManager(KiroCrewConfig()) + key = "chat-arm-2" + mine = tmp_path / "mine" + mine.mkdir() + + generation = mgr.mark_retire_on_next_claim(key, str(mine)) + mgr.supersede_arm_for_new_slot(key, only_generation=generation) + + boundary = mgr._allocation_boundary() + assert not _is_armed(boundary, mgr._fold_key(key)), ( + "the producer's own arm survived its retraction, so a denied request still " + "retires a session it was refused permission to touch" + ) + + def test_an_unscoped_supersede_still_drops_every_arm(self, tmp_path): + """Slot mint and final teardown keep the unconditional drop.""" + from kiro_crew.config import KiroCrewConfig + from kiro_crew.session import SessionManager + + mgr = SessionManager(KiroCrewConfig()) + key = "chat-arm-3" + other = tmp_path / "other" + other.mkdir() + + mgr.mark_retire_on_next_claim(key, str(other)) + mgr.supersede_arm_for_new_slot(key) + + boundary = mgr._allocation_boundary() + assert not _is_armed(boundary, mgr._fold_key(key)) + + +class TestAClearedProjectNeverResurrectsTheOldDirectory: + """The empty-slot merge retains `project`, so every restore path must honor the marker. + + `update_metadata_if` is an upsert: it cannot delete a key. A slot that had `/old` and was then + cleared therefore keeps `"project": "/old"` in its record, and any restore path that reads the + project without the marker resumes writing into a directory the user cleared. + """ + + def test_a_record_carrying_both_resumes_as_cleared_on_every_path(self): + from kiro_crew.config.paths import CWD_CLEARED + from kiro_crew.dashboard.state import _ChatSlot + + # The record a pre-clear save leaves behind. + meta = {"project": "/workspace/old", "project_cleared": True} + + # The pair both the History resume and the channel surfacing perform, in their order. + for label in ("history-resume", "channel-surfacing"): + slot = _ChatSlot("test") + if meta.get("project"): + slot.project = meta["project"] + if meta.get("project_cleared"): + slot.project_cleared = True + assert ( + slot.claim_cwd == CWD_CLEARED + ), f"{label} resumes {slot.claim_cwd!r}, the directory the clear was meant to drop" + + +class TestEveryProducerOfTheClearedMarkerKeepsIt: + """The clear reaches `claim_cwd` only if each producer writes or restores the marker. + + `claim_cwd` reading the marker first is exercised directly above. These pin the three places + that must PUT it there, because dropping any of them silently returns the slot to the + former-directory behaviour with every other test still green. + """ + + def test_the_empty_slot_merge_writes_project_unconditionally(self): + """An upsert cannot delete a key, so a truthy-only write retains the old directory.""" + import inspect + import re + + from kiro_crew.dashboard import chat_persistence + + src = inspect.getsource(chat_persistence._save_slot_to_history) + assert re.search(r'^\s*fields\["project"\] = slot\.project\s*$', src, re.M), ( + "the empty-slot merge no longer writes `project` unconditionally, so a cleared slot " + "keeps its pre-clear directory in the record" + ) + assert not re.search( + r'if slot\.project:\s*\n\s*fields\["project"\]', src + ), "the merge writes `project` only when truthy again" + + def test_both_restore_paths_read_the_cleared_marker(self): + """History resume and channel surfacing each set `slot.project` from metadata.""" + import inspect + + from kiro_crew.dashboard import channel_slots + from kiro_crew.dashboard.chat_handlers import api_chat_slot_resume + + for label, obj in ( + ("channel surfacing", channel_slots.surface_channel_session), + ("history resume", api_chat_slot_resume), + ): + src = inspect.getsource(obj) + assert ( + 'slot.project = meta["project"]' in src + ), f"{label} no longer restores the project here; re-point this control" + assert 'meta.get("project_cleared")' in src, ( + f"{label} restores the project without the cleared marker, so the slot reads as " + "never-scoped and its next claim binds the directory the clear dropped" + ) + + +class TestAStaleClearedMarkerCannotOverrideANewProject: + """A marker retained from an earlier clear must not outrank a project selected after it. + + Sequence: set a project, clear it, persist, select a NEW project, persist, restart. The merge + is an upsert, so the clear's `project_cleared: True` is still in the record when the second + save lands. If that save omits the key, the restored slot reads as cleared and its next claim + binds the default workspace, so relative writes miss the project the user just chose. + """ + + def test_the_second_save_overwrites_the_marker_rather_than_omitting_it(self): + import inspect + import re + + from kiro_crew.dashboard import chat_persistence + + src = inspect.getsource(chat_persistence._save_slot_to_history) + assert re.search(r'^\s*fields\["project_cleared"\] = bool\(', src, re.M), ( + "the merge writes `project_cleared` only when True again, so a clear's marker " + "survives a later project selection and overrides it" + ) + assert not re.search( + r'if getattr\(slot, "project_cleared", False\):\s*\n\s*fields\["project_cleared"\]', + src, + ), "the conditional write is back" + + def test_a_reselected_project_wins_after_the_record_carried_a_clear(self): + from kiro_crew.dashboard.state import _ChatSlot + + # The record after: set /old, clear, persist. + record = {"project": "", "project_cleared": True} + + # The user now selects /new; the save merges these fields over that record. + slot = _ChatSlot("test") + slot.project = "/workspace/new" + slot.project_cleared = False + merged = dict(record) + merged["project"] = slot.project + merged["project_cleared"] = bool(getattr(slot, "project_cleared", False)) + + # Restart: the real restore pair, then the real tri-state. + resumed = _ChatSlot("test") + if merged.get("project"): + resumed.project = merged["project"] + if merged.get("project_cleared"): + resumed.project_cleared = True + + assert resumed.claim_cwd == "/workspace/new", ( + "the resumed slot ignores the project just selected and states " + f"{resumed.claim_cwd!r}, so relative writes land in the default workspace" + ) + + +class TestReopeningAClearedSlotBindsTheProjectItIsGiven: + """Re-scoping a cleared slot must drop the marker the reopen carried forward. + + `claim_cwd` reads the marker before the project on purpose: a project value can outlive its + clear in a record an upsert cannot delete. That makes any site which ASSIGNS a project + responsible for retiring the marker, and the create/reopen fallback did not. + """ + + def test_the_reopen_path_retires_the_marker_when_it_assigns_a_project(self): + import inspect + + from kiro_crew.dashboard import chat_handlers + + src = inspect.getsource(chat_handlers.api_chat_slot_create) + assign = src.find("slot.project = cfg_proj or default_project_dir(workspace)") + retire = src.find("slot.project_cleared = False") + assert assign != -1, "the default assignment moved; re-point this control" + assert retire != -1, ( + "the reopen path assigns a project without retiring the cleared marker, so the " + "next turn binds the fallback workspace instead of the project just assigned" + ) + assert assign < retire, "the marker is retired before the assignment that needs it" + + def test_a_reassigned_project_wins_over_the_carried_marker(self): + from kiro_crew.dashboard.state import _ChatSlot + + # State after: set a project, clear it, reopen -- the restore carries the marker. + slot = _ChatSlot("test") + slot.project = "" + slot.project_cleared = True + + # What the create/reopen fallback now does when it re-scopes the slot. + slot.project = "/workspace/assigned" + if slot.project: + slot.project_cleared = False + + assert slot.claim_cwd == "/workspace/assigned", ( + "the reopened slot states {!r} rather than the project it was just assigned, so " + "its turn runs in the fallback workspace".format(slot.claim_cwd) + ) + + +class TestARebindInsideTheClearedResolveDoesNotStrandTheArm: + """A rebind landing inside the cleared-cwd resolve must not strand the arm. + + The handler read the effective key ONCE and then awaited ``resolve_arm_cwd``; + the transfer and the deferred reset both used that pre-await snapshot. A slot + that rebound during the resolve was therefore armed on the key it had already + abandoned, and the next turn on the live key bound the pre-change directory + with no arm to correct it. + """ + + @pytest.mark.asyncio + async def test_the_arm_follows_the_key_the_slot_holds_after_the_resolve(self, tmp_path): + from kiro_crew.config import KiroCrewConfig + from kiro_crew.dashboard.chat_handlers import effective_session_key + from kiro_crew.session import SessionManager + + alpha, beta = tmp_path / "alpha", tmp_path / "beta" + alpha.mkdir() + beta.mkdir() + + slot = _ChatSlot("test") + slot.project = str(alpha) + state = _mock_state(slot) + seen: list = [] + mgr = SessionManager( + KiroCrewConfig(), + provider_factory=TestWorkspaceSwitchAdvancesTheGeneration._factory(seen), + ) + state.sessions = mgr + + stale_key = "slack:rebind-first" + rebound_key = "slack:rebind-second" + + original_resolve = mgr.resolve_arm_cwd + + def save_then_rebind_and_clear(_project): + # Two lock-free writers land in this await: the in-turn set_project directive + # clears the project, and a linked_session_key writer rebinds the slot. + slot.linked_session_key = stale_key + slot.project = "" + + async def resolve_then_rebind(key, cwd): + resolved = await original_resolve(key, cwd) + # THE WINDOW: a second rebind lands while this resolve is suspended, which is + # precisely what a key captured before the await cannot observe. + if slot.linked_session_key == stale_key: + slot.linked_session_key = rebound_key + return resolved + + with ( + patch.object(mgr, "resolve_arm_cwd", new=resolve_then_rebind), + patch("kiro_crew.dashboard.chat_handlers.schedule_eager_spawn", new=MagicMock()), + patch( + "kiro_crew.dashboard.chat_handlers._save_recent_project", + new=MagicMock(side_effect=save_then_rebind_and_clear), + ), + ): + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/project", json={"project": str(beta)} + ) + assert resp.status == 409, await resp.text() + assert "session_rebound" in await resp.text() + + assert effective_session_key(slot) == rebound_key, "precondition: the rebind did not land" + + boundary = mgr._allocation_boundary() + stale_arm = _armed_cwd(boundary, mgr._fold_key(stale_key)) + assert stale_arm is None, ( + "the arm was left on the key the slot abandoned during the resolve, so the live " + f"session keeps the pre-change directory; stale arm={stale_arm!r}" + ) + assert slot._pending_reset_history_key != stale_key, ( + "the deferred reset still names the abandoned key, so it tears down a session " + "nobody is on and leaves the live one at the old cwd" + ) + + # Behavioural half: the refusal committed nothing, so the claim on the LIVE key must + # not have been armed toward the project this request was refused for. + provider, _, _ = await mgr.get_or_create(effective_session_key(slot)) + assert provider.cwd != str(beta), ( + "a refused switch still steered the live session at the project it declined to " + f"commit; cwd={provider.cwd!r}" + ) + + +class TestTheArmRecordKeepsWhatSpendingMustNotDrop: + """The generation outlives the arm, which is the whole reason the two live in one record.""" + + def test_spending_drops_the_arm_and_keeps_the_generation(self): + """A spent arm names nothing, but a start still compares against the counter. + + Dropping the generation here would make a start that began BEFORE the change read as + current and be served, which is the staleness the counter exists to catch. + """ + arm = RetireArm(generation=7, cwd="/projects/beta", agent="research") + + arm.spend() + + assert arm.generation == 7, "spending dropped the counter a stale start is judged against" + assert arm.cwd is None + assert arm.agent is None diff --git a/test/test_chat_slot_switch_atomicity.py b/test/test_chat_slot_switch_atomicity.py index f1c38e248f7..b284cb3f8c2 100644 --- a/test/test_chat_slot_switch_atomicity.py +++ b/test/test_chat_slot_switch_atomicity.py @@ -12,13 +12,19 @@ from __future__ import annotations +import ast import asyncio +from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiohttp import web from aiohttp.test_utils import TestClient, TestServer +from chat_test_helpers import _make_state +from kiro_crew.config.sections import ResolvedBindings +from kiro_crew.dashboard import chat_handlers from kiro_crew.dashboard.chat import ( api_chat_slot_agent, api_chat_slot_model, @@ -28,7 +34,9 @@ api_chat_slots_model, ) from kiro_crew.dashboard.chat_handlers import _slot_switch_session_lock +from kiro_crew.dashboard.chat_utils import effective_session_key from kiro_crew.dashboard.state import DashboardState, _ChatSlot +from kiro_crew.memory_stores import DEFAULT_MEMORY_STORE MOD = "kiro_crew.dashboard.chat_handlers" @@ -39,6 +47,24 @@ _MODEL_B = "gpt-5.6-sol" +def _bindings_for(**overrides) -> ResolvedBindings: + """A real `ResolvedBindings`, so a field added to it arrives with its default here. + + The handler reads several of these fields inside its own ``try``, where a missing attribute + is swallowed -- the commit compare-and-set is then skipped and the arm carries the project + the switch was leaving, which reads as a behaviour regression rather than a stale stub. + """ + return ResolvedBindings( + **{ + "workspace_dir": Path("/ws"), + "memory_store_name": DEFAULT_MEMORY_STORE, + "effective_memory_config": {}, + "kiro_agent": "ka", + **overrides, + } + ) + + def _make_app(state: DashboardState) -> web.Application: # Mirror production: token_auth middleware sets request["app"] on every # authenticated path ("" = dashboard user); the bulk handler fails closed @@ -60,6 +86,10 @@ async def dashboard_auth_marker(request, handler): return app +def _boom_cfg(): + raise RuntimeError("config unreadable") + + def _mock_state(slot: _ChatSlot, provider: object = None) -> DashboardState: state = MagicMock(spec=DashboardState) state._slots = {slot.key: slot} @@ -67,6 +97,14 @@ def _mock_state(slot: _ChatSlot, provider: object = None) -> DashboardState: state.broadcast_context_usage = MagicMock() state.sessions = MagicMock() state.sessions.reset = AsyncMock() + # Async because it resolves a cleared project off-thread; a plain MagicMock returns a + # non-awaitable here and the handler answers 500 instead of exercising the switch. + state.sessions.note_project_change = AsyncMock() + # Resolve-only helper: async, and it must hand back a real path string because the arm + # sites record `slot.project or `. + state.sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/workspace/_default" + ) # No live AcpProvider by default → the model handler takes the reset path. state.sessions.get_provider = MagicMock(return_value=provider) return state @@ -175,6 +213,48 @@ async def test_unreadable_pin_refuses_without_reset(self, private_switch_state): state.sessions.reset.assert_not_awaited() +class TestSlotAgentSwitchArmOrdering: + @pytest.mark.asyncio + async def test_new_agent_is_not_visible_before_the_arm_is_raised(self, monkeypatch): + """A claim landing mid-resolve must not see the new agent with no arm. + + The agent switch resolves bindings behind an await. If the slot publishes the new + agent before that await, a cwd-less channel claim acquiring in the window reads + the new agent off the slot while no retirement arm has been recorded yet, so it + reuses the OLD session and runs the turn under the stale agent and project. + """ + slot = _ChatSlot(key="chat-1", agent="old-agent") + slot.project = "" + state = _mock_state(slot) + armed: list = [] + state.sessions.mark_retire_on_next_claim = MagicMock( + side_effect=lambda key, cwd, agent=None: armed.append(agent) + ) + + observed: dict = {} + + async def _warm_and_observe(project, **kwargs): + # This is the suspension point. Whatever a competing claim could read, it + # reads HERE. + observed["agent"] = str(slot.agent) + observed["armed"] = list(armed) + + monkeypatch.setattr( + chat_handlers, "warm_project_agent_names", _warm_and_observe, raising=False + ) + + app = _make_app(state) + async with TestClient(TestServer(app)) as client: + await client.post("/api/chat/slots/chat-1/agent", json={"agent": "new-agent"}) + + assert observed, "the resolve never ran, so the window was never observed" + assert not (observed["agent"] == "new-agent" and not observed["armed"]), ( + "the switch published the new agent while the arm was still unraised, so a " + "claim in this window reuses the old session under the new agent" + ) + await asyncio.sleep(0) + + class TestSlotModelSwitchAtomicity: @pytest.mark.asyncio async def test_mid_turn_switch_answers_409_without_reset(self): @@ -259,6 +339,73 @@ async def _set_model_starts_a_send(*args, **kwargs): # clearing) is never entered while the slot is busy. state.sessions.reset.assert_not_awaited() + @pytest.mark.asyncio + async def test_a_turn_selecting_its_own_agent_mid_switch_is_not_overwritten(self): + """The agent commit joins its siblings' compare-and-set instead of clobbering. + + `slot.workspace` and `slot.project` commit only when they still hold the value read + before the awaits. The agent did not: it committed unconditionally, so a turn starting + inside those awaits -- which selects its own agent -- had that pick erased by the + commit, and erased again by the rollback. There is no recovery once overwritten. + """ + slot = _ChatSlot("test") + slot.agent = "agent-before" + state = _mock_state(slot) + + async def _turn_picks_its_own_agent(*args, **kwargs): + # A turn dispatches while the arm's directory is being resolved and binds the + # agent it was started with, writing without this handler's lock. + slot.agent = "agent-the-turn-picked" + return "" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_turn_picks_its_own_agent) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "agent-asked"}) + body = await resp.text() + + assert resp.status == 409, f"got {resp.status}: {body[:220]}" + assert '"code": "turn_in_flight"' in body or '"code":"turn_in_flight"' in body, ( + "the refusal must be the concurrent-pick one, not another 409 that would make this " + f"control vacuous: {body[:220]}" + ) + assert slot.agent == "agent-the-turn-picked", ( + "the switch overwrote an agent the running turn selected, and no rollback can " + f"recover it: {slot.agent!r}" + ) + + @pytest.mark.asyncio + async def test_a_project_set_mid_clear_is_not_overwritten(self, tmp_path): + """The project commit re-reads the value it resolved against. + + `api_chat_slot_project` resolves the arm's directory through an await, then committed + unconditionally. An in-turn `set_project` directive writes without this lock, so a + change made inside that window was overwritten by a commit computed from the project + it replaced. + """ + before = tmp_path / "before" + asked = tmp_path / "asked" + by_turn = tmp_path / "by-turn" + for d in (before, asked, by_turn): + d.mkdir() + slot = _ChatSlot("test") + slot.project = str(before) + state = _mock_state(slot) + + async def _directive_sets_a_new_project(*args, **kwargs): + slot.project = str(by_turn) + return "" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_directive_sets_a_new_project) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/project", json={"project": str(asked)}) + body = await resp.text() + + assert resp.status == 409, f"got {resp.status}: {body[:200]}" + expected = str(by_turn) + assert ( + slot.project == expected + ), f"the clear overwrote a project the running turn set: {slot.project!r}" + @pytest.mark.asyncio async def test_turn_on_target_model_during_live_switch_succeeds(self): # The counterpart to the 409 above: set_model LANDED, then the effort @@ -860,6 +1007,133 @@ async def test_reset_declined_busy_rolls_back_and_answers_409(self): assert slot.project == "/workspace/old-ws" assert state.sessions.reset.await_args.kwargs == {"skip_if_busy": True} + @pytest.mark.asyncio + async def test_a_failed_resolution_leaves_no_project_armed(self): + """Nothing may be armed by a switch that answers 503. + + The recording ran BEFORE the resolution that can raise, so an unavailable workspace root + left the arm naming the project this request then refused to commit -- and the next claim + followed it into the rejected workspace. The resolve goes first, so a failure arms nothing. + """ + slot = _ChatSlot("test") + slot.workspace = "old-ws" + slot.project = "/workspace/old-ws" + state = _mock_state(slot, provider=None) + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=OSError("root unreadable")) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + assert resp.status == 503 + state.sessions.note_project_change.assert_not_awaited() + assert slot.workspace == "old-ws" + + @pytest.mark.asyncio + async def test_minting_a_slot_supersedes_a_previous_occupants_arm(self, tmp_path): + """The supersede must be wired to slot CREATION, not just available on the manager. + + Closing a tab runs ``remove``, which preserves the arm on purpose, so the arm outlives + the slot. Nothing else can then drop it: the next occupant of that name is a different + slot, and an arm naming the old project silently redirects its relative writes. Reuse + of a live slot returns earlier, which keeps the retry target ``remove`` preserves it + for. + """ + state = _make_state(tmp_path / "sessions") + superseded: list[str] = [] + state.sessions.supersede_arm_for_new_slot = lambda key: superseded.append(key) + + first = state.get_or_create_slot("chat-9") + assert superseded == ["dashboard:chat-9"], ( + "minting a slot did not supersede the key's arm, so a previous occupant's " + f"armed project survives into it; calls seen: {superseded}" + ) + + superseded.clear() + again = state.get_or_create_slot("chat-9") + assert again is first + assert superseded == [], ( + "reusing a live slot must NOT drop the arm -- it is still owed to a start the " + "cleanup evicted, whose own frame carries only the pre-change directory" + ) + + @pytest.mark.asyncio + async def test_a_project_written_during_the_awaits_is_not_erased(self): + """The commit must not overwrite a project another writer chose mid-await. + + The arm recording and its cleared-default resolution both run BEFORE the fields + commit, and `slot.project` has unlocked writers -- the in-turn `_set_project` + directive among them. An unconditional assignment after those awaits therefore + discards the project the user selected during the window, with nothing to signal it. + The commit is compare-and-set, and when it loses the arm is re-pointed at the value + that survived so the next claim binds what the slot actually carries. + """ + slot = _ChatSlot("test") + slot.workspace = "old-ws" + slot.project = "/workspace/old-ws" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + + async def _writer_lands_mid_await(key, cwd): + # Stands in for the unlocked in-turn writer landing inside this await window. + slot.project = "/writer/chose-this" + return cwd or "/workspace/_default" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_writer_lands_mid_await) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + assert resp.status == 200 + assert slot.project == "/writer/chose-this", ( + "the switch overwrote a project written during its own await window; the " + f"user's selection is gone with nothing to signal it; got {slot.project!r}" + ) + armed = [c.args[1] for c in state.sessions.note_project_change.await_args_list] + assert armed[-1] == "/writer/chose-this", ( + "when the compare-and-set loses, the arm must be re-pointed at the project " + f"that survived, or the next claim binds a directory the slot left; got {armed}" + ) + + @pytest.mark.asyncio + async def test_a_rebind_transfers_an_arm_cwd_resolved_for_the_live_key(self, monkeypatch): + """A followed arm must name the LIVE key's directory, not the abandoned key's. + + With no configured project the arm carries the CLEARED cwd, which is resolved for + the key read before the awaits. If a channel/cron link reassigns the slot in that + window, transferring that same cwd arms the live key at a directory no live-key + provider binds: the live session is evicted and its won-race retry rebinds into a + scratch dir shared with the abandoned key's own sessions. + """ + slot = _ChatSlot("test") + slot.workspace = "old-ws" + slot.project = "" + slot.project_cleared = True + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + monkeypatch.setattr(chat_handlers, "default_project_dir", lambda ws: "") + + async def _link_lands_mid_await(key, cwd): + slot.linked_session_key = "slack:live-1" + return f"/workspace/{key}" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_link_lands_mid_await) + state.sessions.transfer_retire_arm = MagicMock() + + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + # A rebind mid-switch answers `session_rebound` by design; the arm still had to + # follow the slot before that refusal, and this pins WHERE it points. + assert resp.status == 409 + assert ( + state.sessions.transfer_retire_arm.call_args is not None + ), "the slot rebound during the resolve but no arm was transferred" + live_key, armed_cwd = state.sessions.transfer_retire_arm.call_args.args[1:3] + assert live_key == "slack:live-1" + assert armed_cwd == "/workspace/slack:live-1", ( + "the arm was transferred to the live key carrying the ABANDONED key's " + f"directory, so no live-key provider binds it; got {armed_cwd!r}" + ) + @pytest.mark.asyncio async def test_active_turn_is_refused_before_the_commit(self): # GPT review finding: the reset path calls @@ -991,6 +1265,115 @@ async def test_rollback_unwinds_a_same_text_own_commit(self): assert resp.status == 409 assert slot.project == "/workspace/old-ws" + @pytest.mark.asyncio + async def test_a_rejected_switch_does_not_erase_a_project_written_mid_await(self): + """The ROLLBACK must not clobber the writer either -- same class as the commit. + + A compare-and-set commit that loses leaves the slot carrying the writer's project, + so restoring `prior_project` on the refusal path erases exactly what the CAS just + protected. Both fields unwind only while this request still owns them. + """ + from kiro_crew.providers.base import LLMProvider + + slot = _ChatSlot("test") + slot.workspace = "old-ws" + slot.project = "/workspace/old-ws" + busy = MagicMock(spec=LLMProvider) + # Idle at the pre-commit refusal, mid-turn at the post-decline re-read: the turn + # slipped in during the reset, which is the only path that reaches the rollback. + busy.has_active_turn.side_effect = [False, True] + state = _mock_state(slot, provider=busy) + state.sessions.reset = AsyncMock(return_value=False) + state.conversation_log = MagicMock() + + async def _writer_lands_mid_await(key, cwd): + slot.project = "/writer/chose-this" + return cwd or "/workspace/_default" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_writer_lands_mid_await) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + assert resp.status == 409 + assert slot.project == "/writer/chose-this", ( + "the refusal rolled back over a project this request never committed; got " + f"{slot.project!r}" + ) + + @pytest.mark.asyncio + async def test_a_rejected_switch_repoints_the_arm_with_an_already_resolved_path(self): + """The rollback must not be the thing that resolves a cleared directory. + + With an EMPTY prior project the re-point passed the cleared sentinel, and the recorder + resolves that off-thread through the workspace root's stat/realpath -- which raises on + an unavailable root. On a rollback path that raise escapes as a 500 AFTER the slot + fields were restored, while the retirement arm still names the REJECTED project, so the + next claim binds the workspace this request just refused. Resolving in the guarded + pre-commit window instead means every rollback re-points with a concrete directory. + """ + from kiro_crew.providers.base import LLMProvider + + slot = _ChatSlot("test") + slot.workspace = "old-ws" + # The cleared case: this is what made the rollback resolve, and re-raise, on the + # one path that cannot afford to. The MARKER is what distinguishes it from a slot + # that was never scoped, which states no directory and so arms none. + slot.project = "" + slot.project_cleared = True + busy = MagicMock(spec=LLMProvider) + # Idle at the pre-commit refusal, mid-turn at the post-decline re-read: only the + # slipped-in turn reaches the rollback where the re-point happens. + busy.has_active_turn.side_effect = [False, True] + state = _mock_state(slot, provider=busy) + state.sessions.reset = AsyncMock(return_value=False) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + data = await resp.json() + assert resp.status == 409 + assert data["code"] == "turn_in_flight" + assert slot.workspace == "old-ws" + repointed = state.sessions.note_project_change.await_args_list[-1].args[1] + assert repointed == "/workspace/_default", ( + "the rollback must re-point with a directory already resolved before the " + f"commit; passing the cleared sentinel resolves on this path and can raise a " + f"500 with the rejected project still armed; got {repointed!r}" + ) + + @pytest.mark.asyncio + async def test_a_failed_arm_record_leaves_the_workspace_switch_uncommitted(self): + """The resolve can raise, so it must run BEFORE the fields move, not after. + + Resolving a cleared default goes through `workspace_root()`, whose mkdir raises on an + unavailable root. Resolving after the commit makes that a 500 with the new workspace + already on the slot -- a half-applied switch the caller cannot see or undo. Ordering + every resolve first makes the failure a clean, retryable 503. + + The record itself cannot raise: it is handed an already-resolved cwd, which is what + lets it publish synchronously after the commit without reopening the window where a + claim reads an arm the compare-and-set has not finalized. + """ + slot = _ChatSlot("test") + slot.workspace = "old-ws" + slot.project = "/workspace/old-ws" + state = _mock_state(slot, provider=None) + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=OSError("root unreadable")) + recorded: list[str] = [] + state.sessions.note_project_change = AsyncMock( + side_effect=lambda key, cwd: recorded.append(cwd) + ) + state.sessions.reset = AsyncMock(return_value=True) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + data = await resp.json() + assert resp.status == 503 + assert data["code"] == "workspace_unavailable" + assert slot.workspace == "old-ws" + assert slot.project == "/workspace/old-ws" + state.sessions.reset.assert_not_awaited() + assert ( + not recorded + ), f"a project change was recorded behind the 503, arming a rejected switch; {recorded}" + @pytest.mark.asyncio async def test_reset_declined_idle_session_retries_once(self): # An idle live session declined the first reset (a slipped-in first @@ -1243,6 +1626,211 @@ class TestLinkedSlotSessionKey: so an app caller may not switch a channel thread's model. """ + @pytest.mark.asyncio + async def test_a_switch_onto_an_empty_project_records_the_clear(self): + """A workspace whose project dir resolves to "" must leave the slot CLEARED, not unset. + + `default_project_dir` answers "" for a missing or sensitive root. Committing that empty + project without recording the clear makes the slot indistinguishable from one that never + had a project, so its next claim states no cwd, keeps the stored-cwd resume override, and + binds the directory the switch was meant to leave. + """ + from kiro_crew.config.paths import CWD_CLEARED + + slot = _ChatSlot("test") + slot.project = "/workspace/before" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + + with patch.object(chat_handlers, "default_project_dir", return_value=""): + async with TestClient(TestServer(_make_app(state))) as client: + await client.post("/api/chat/slots/test/workspace", json={"workspace": "other"}) + + assert slot.project == "" + assert slot.claim_cwd == CWD_CLEARED, ( + "the switched slot states no cwd, so a claim keeps the resume override and binds " + f"the previous project; claim_cwd={slot.claim_cwd!r}" + ) + + @pytest.mark.asyncio + async def test_a_concurrent_clear_does_not_arm_the_workspace_it_rejected(self): + """A clear that wins the compare-and-set must not leave this request's project armed. + + The fallback used when the slot ends with no project has to be a CLEARED resolution. + Seeding it from the candidate workspace means a clear landing during the resolve is + answered with an arm naming the very directory it just rejected, so the next claim + binds there. + """ + slot = _ChatSlot("test") + slot.project = "/workspace/before" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + armed: list[str] = [] + state.sessions.transfer_retire_arm = MagicMock( + side_effect=lambda frm, to, cwd: armed.append(cwd) + ) + + async def _clear_wins_during_resolve(key, cwd): + slot.project = "" + return "/defaults/resolved" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_clear_wins_during_resolve) + + with patch.object(chat_handlers, "default_project_dir", return_value="/workspace/other"): + async with TestClient(TestServer(_make_app(state))) as client: + await client.post("/api/chat/slots/test/workspace", json={"workspace": "other"}) + + assert armed, "no arm was transferred, so this test is not exercising the fallback" + assert "/workspace/other" not in armed, ( + "the arm names the workspace the concurrent clear rejected, so the next claim binds " + f"a project the slot is not on; armed={armed}" + ) + + @pytest.mark.asyncio + async def test_an_empty_project_arm_resolves_for_the_key_it_lands_on(self): + """The per-session default differs per key, so it must follow the transfer. + + With no project the arm names the per-session default. Resolving that for the key the + request started on and then transferring it to the key the slot rebound to arms a + directory no live-key provider binds, so the winning claim is evicted and its retry + binds a scratch directory instead of the session's own default. + """ + slot = _ChatSlot("test") + slot.project = "" + slot.project_cleared = True + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + armed: list[tuple[str, str]] = [] + state.sessions.transfer_retire_arm = MagicMock( + side_effect=lambda frm, to, cwd: armed.append((to, cwd)) + ) + + async def _default_per_key(key, cwd): + # The rebind lands inside the resolve, as an in-turn link does. + slot.linked_session_key = "slack:live-1" + return f"/defaults/{key}" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_default_per_key) + + with patch.object(chat_handlers, "default_project_dir", return_value=""): + async with TestClient(TestServer(_make_app(state))) as client: + await client.post("/api/chat/slots/test/workspace", json={"workspace": "other"}) + + assert armed, "no arm was transferred, so this test is not exercising the empty arm" + mismatched = [(to, cwd) for to, cwd in armed if cwd != f"/defaults/{to}"] + assert not mismatched, ( + "an arm names a per-session default resolved for a different key than the one it " + f"landed on, so no provider on that key binds it; mismatched={mismatched}" + ) + + @pytest.mark.asyncio + async def test_the_arm_names_the_project_that_won_the_compare_and_set(self): + """The arm must publish AFTER the project is final, not before. + + The workspace switch resolves off-thread, and an in-turn `set_project` directive can + land in that window -- which is exactly what the compare-and-set below exists to + preserve. Publishing the arm first leaves a claim following a directory the CAS then + declines to keep, so relative writes run outside the project the user selected. + """ + slot = _ChatSlot("test") + slot.project = "/workspace/before" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + + async def _in_turn_writer_wins(key, cwd): + # The racing in-turn directive, landing inside the resolve the switch awaits. + slot.project = "/workspace/picked-by-user" + return cwd or "/workspace/_default" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_in_turn_writer_wins) + # Each publish is recorded with the project LIVE at that instant: a final-state check + # cannot see a transient exposure a later re-point repairs. + publishes: list[tuple[str, str, str]] = [] + state.sessions.note_project_change = AsyncMock( + side_effect=lambda key, cwd: publishes.append(("note", cwd, slot.project)) + ) + state.sessions.transfer_retire_arm = MagicMock( + side_effect=lambda frm, to, cwd: publishes.append(("transfer", cwd, slot.project)) + ) + + async with TestClient(TestServer(_make_app(state))) as client: + await client.post("/api/chat/slots/test/workspace", json={"workspace": "other"}) + + assert publishes, "nothing was published, so this test is not exercising the arm" + disagreed = [p for p in publishes if p[1] != p[2]] + assert not disagreed, ( + "an arm was published naming a project the slot was not on, so a claim in that " + f"window binds a directory the compare-and-set declines to keep; {disagreed}" + ) + + @pytest.mark.asyncio + async def test_a_denied_rebind_unwinds_the_published_agent_switch(self): + """A computed denial must be honored, not discarded. + + The binding triple commits BEFORE the reset so a send landing in the teardown sees + the new bindings. Discarding the denial lets the request carry on under them: the + caller's agent stays published on a session it has no claim on until a later check + unwinds it, and the answer it finally gets is a 409 naming the rebind rather than the + gate's own indistinguishable refusal. + """ + slot = _ChatSlot("test") + slot._app = "owner-app" + slot.agent = "kirocrew" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + + async def _link_lands_mid_resolve(key, cwd): + slot.linked_session_key = "slack:foreign-1" + return f"/workspace/{key}" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_link_lands_mid_resolve) + + async with TestClient(TestServer(_make_app_as(state, "owner-app"))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "other"}) + + assert resp.status == 404, f"the denial was not returned to the caller: {resp.status}" + assert slot.agent == "kirocrew", ( + "the agent switch stayed published after the rebind was denied, so a caller " + f"with no claim on the live session repointed it; slot.agent={slot.agent!r}" + ) + + @pytest.mark.asyncio + async def test_a_rebind_to_an_unauthorized_session_arms_neither_key(self): + """An app caller must not arm a session its own gate would refuse. + + The gate clears the key read inside the lock. A concurrent cron/channel link can + then rebind the slot, and transferring the arm onto that key would repoint the + bindings of a conversation this caller has no claim on -- its next claim would run + under them. On denial the source arm is retracted so no arm survives on either key. + """ + slot = _ChatSlot("test") + slot._app = "owner-app" + slot.agent = "kirocrew" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.sessions.supersede_arm_for_new_slot = MagicMock() + state.conversation_log = MagicMock() + + async def _link_lands_mid_resolve(key, cwd): + slot.linked_session_key = "slack:foreign-1" + return f"/workspace/{key}" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_link_lands_mid_resolve) + + async with TestClient(TestServer(_make_app_as(state, "owner-app"))) as client: + await client.post("/api/chat/slots/test/agent", json={"agent": "other"}) + + assert state.sessions.transfer_retire_arm.call_args is None, ( + "the arm was transferred onto a session the caller's own gate refuses, so its " + "next claim runs under bindings this caller had no claim to set" + ) + retracted = [c.args[0] for c in state.sessions.supersede_arm_for_new_slot.call_args_list] + assert "dashboard:test" not in retracted, ( + "the denied switch spent an arm it never wrote -- a project arm resident before " + f"the request -- leaving a cwd-less claim on the stale project; retracted={retracted}" + ) + @pytest.mark.asyncio async def test_model_switch_probes_and_resets_the_linked_session(self): slot = _ChatSlot("test") @@ -1481,43 +2069,519 @@ def _boom(): assert meta_call.args[0] == "dashboard:test" @pytest.mark.asyncio - async def test_agent_switch_sees_the_linked_sessions_active_turn(self): - # The busy probe lands on the live linked session: an in-flight - # channel turn answers 409 instead of tearing the turn (or a - # captured-identity session) down. - from kiro_crew.providers.acp import AcpProvider + async def test_agent_switch_arms_the_project_that_survives_the_switch(self, monkeypatch): + """A project that survives the switch is what the arm carries, not the default. + The fallback is resolved unconditionally (an unlocked concurrent clear can empty + `slot.project` after any gate on the candidate projects), so the resolve happening is + not the question -- what matters is that the arm still prefers the live project and + never records a directory the slot is not on. + """ + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", _boom_cfg) slot = _ChatSlot("test") slot.agent = "old-agent" - slot.linked_session_key = "slack:123.456" - provider = MagicMock(spec=AcpProvider) - provider.has_active_turn.return_value = True + slot.project = "/Users/alice/proj" state = _mock_state(slot, provider=None) - state.sessions.get_provider = MagicMock( - side_effect=lambda key: provider if key == "slack:123.456" else None - ) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() async with TestClient(TestServer(_make_app(state))) as client: resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) - data = await resp.json() - assert resp.status == 409 - assert data["code"] == "turn_in_flight" - assert slot.agent == "old-agent" - state.sessions.reset.assert_not_awaited() + assert resp.status == 200 + assert state.sessions.mark_retire_on_next_claim.call_args.args[1] == "/Users/alice/proj" @pytest.mark.asyncio - async def test_app_caller_cannot_switch_a_linked_sessions_agent(self): - # Owning the slot is not owning the channel session it is bound to: - # denied as an indistinguishable 404, nothing mutated. + async def test_an_agent_switch_leaves_an_unscoped_slot_unscoped(self, monkeypatch): + """A slot that never had a project must not come out of a switch reading as CLEARED. + + `project_cleared` is what stops a slot resuming its conversation and sends its claim + past the warm pool. Deriving it from "the new project is empty" marks a slot the user + never scoped, so an agent switch alone would silently cost that slot its history and a + cold start -- and the arm would name the per-session default rather than stating no + directory, so the next turn's relative writes land outside whatever it was resuming. + """ + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", _boom_cfg) slot = _ChatSlot("test") slot.agent = "old-agent" - slot._app = "demo-app" - slot.linked_session_key = "slack:123.456" + slot.project = "" + slot.project_cleared = False state = _mock_state(slot, provider=None) - async with TestClient(TestServer(_make_app_as(state, "demo-app"))) as client: + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) - assert resp.status == 404 - assert slot.agent == "old-agent" - state.sessions.reset.assert_not_awaited() + assert resp.status == 200 + + assert ( + slot.project_cleared is False + ), "the switch converted a slot that was never scoped into an explicitly cleared one" + assert ( + state.sessions.mark_retire_on_next_claim.call_args.args[1] is None + ), "the arm named the cleared default for a slot that states no directory" + + @pytest.mark.asyncio + async def test_a_rebind_during_the_switch_moves_the_arm_to_the_live_key(self, monkeypatch): + """The arm must guard the key the slot ENDS on, not the one captured before the awaits. + + `session_key` is read once before the resolve/reset awaits, and `linked_session_key` is + assigned outside `slot._lock`, so a channel link landing mid-transaction leaves the arm + on an abandoned key. The claim gate is per-key with no cross-key fallback, so the live + key is then unguarded and the channel reuses the temporary agent and CWD -- writing in + the project this request refused. + """ + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", _boom_cfg) + slot = _ChatSlot("test") + slot.agent = "old-agent" + slot.project = "/Users/alice/proj" + from kiro_crew.providers.base import LLMProvider + + busy = MagicMock(spec=LLMProvider) + # Idle at the pre-commit probe so the switch reaches the arm, busy at the re-probe + # after the awaits so the rollback -- the path that arms the stale key -- runs. + busy.has_active_turn.side_effect = [False, True] + [True] * 8 + state = _mock_state(slot, provider=busy) + state.conversation_log = MagicMock() + state.sessions.reset = AsyncMock(return_value=True) + + async def _rebind_then_resolve( + key, cwd + ): # A channel link landing during the resolve await, the way cron_inject does. + slot.linked_session_key = "slack:999.111" + return cwd or "/workspace/_default" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_rebind_then_resolve) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + assert resp.status == 409 + moved = state.sessions.transfer_retire_arm.call_args + assert moved is not None, ( + "the arm was never re-pointed after the rebind, so it still guards the abandoned " + "key while the live one accepts the refused agent and project" + ) + assert moved.args[0] == "dashboard:test" + assert ( + moved.args[1] == "slack:999.111" + ), f"the arm must move to the key the slot now runs on; moved to {moved.args[1]!r}" + + @pytest.mark.asyncio + async def test_a_workspace_rollback_moves_the_arm_to_the_live_key(self): + """The workspace handler's rollback paths must arm the key the slot ENDS on. + + `session_key` is read once before the cleared resolve, and `linked_session_key` is + assigned outside `slot._lock`, so a channel link landing in that await leaves every + rollback arming an abandoned key. The claim gate is per-key with no cross-key + fallback, so later channel turns on the live key reuse the rejected binding -- the + agent handler already follows the rebind; these paths did not. + """ + from kiro_crew.providers.base import LLMProvider + + slot = _ChatSlot("test") + slot.workspace = "old-ws" + # Cleared, so the resolve actually awaits: a non-empty project short-circuits it. + slot.project = "" + busy = MagicMock(spec=LLMProvider) + busy.has_active_turn.side_effect = [False, True] + [True] * 8 + state = _mock_state(slot, provider=busy) + state.conversation_log = MagicMock() + state.sessions.reset = AsyncMock(return_value=False) + + async def _rebind_then_resolve(key, cwd): + # A channel link landing during the resolve await, the way cron_inject does. + slot.linked_session_key = "slack:777.222" + return cwd or "/workspace/_default" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_rebind_then_resolve) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + assert resp.status == 409 + moved = state.sessions.transfer_retire_arm.call_args + assert moved is not None, ( + "the rejected workspace switch left the arm on the abandoned key, so the live " + "session keeps the binding this request refused" + ) + assert moved.args[1] == "slack:777.222", ( + "the arm must move to the key the slot now runs on; moved to " f"{moved.args[1]!r}" + ) + + @pytest.mark.asyncio + async def test_a_rejected_switch_arms_the_configured_default_agent(self, monkeypatch): + """An EMPTY restored agent must arm `config.default_agent`, not the built-in name. + + The empty selection runs the configured default, so a rollback that falls back to + the hardcoded `"kirocrew"` arms an agent the session will never run: the retirement + retry then starts the built-in while the binding names the operator's own default, + and the session comes up on the wrong agent. The commit-side arm already prefers + `default_alias`; the rollback did not. + """ + resolved = SimpleNamespace(workspace="ws", memory_store="ms", kiro_agent="target") + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.resolve_agent_bindings", + lambda cfg, name=None, project=None: resolved, + ) + cfg = MagicMock() + cfg.default_agent = "operators-own-default" + cfg.agents = {} + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", lambda: cfg) + from kiro_crew.providers.base import LLMProvider + + slot = _ChatSlot("test") + # EMPTY prior agent: this is what makes the restored value falsy on the rollback. + slot.agent = "" + slot.project = "/Users/alice/proj" + busy = MagicMock(spec=LLMProvider) + # Idle at the pre-commit probe so the switch commits, busy at the re-probe so the + # rollback -- the only path carrying the fallback -- actually runs. + busy.has_active_turn.side_effect = [False, True] + [True] * 8 + state = _mock_state(slot, provider=busy) + state.conversation_log = MagicMock() + state.sessions.reset = AsyncMock(return_value=True) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": ""}) + assert resp.status == 409 + armed = state.sessions.mark_retire_on_next_claim.call_args + assert armed is not None, "the rollback never armed an agent" + assert armed.kwargs["agent"] == "operators-own-default", ( + "the rollback armed the built-in default instead of the configured one, so the " + f"retirement retry starts a different agent than the session runs; got " + f"{armed.kwargs['agent']!r}" + ) + + @pytest.mark.asyncio + async def test_a_named_switch_rollback_arms_the_configured_default_agent(self, monkeypatch): + """`default_alias` must be populated for a NAMED switch too, not just an empty one. + + It was assigned only in the empty-selection branch, so a switch that NAMES an agent + left it "" -- and a rollback restoring an empty prior agent (a slot that was running + the configured default) then armed the built-in literal. The retirement retry starts + a different agent than the session will run, on a security-class path. + """ + resolved = SimpleNamespace(workspace="ws", memory_store="ms", kiro_agent="target") + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.resolve_agent_bindings", + lambda cfg, name=None, project=None: resolved, + ) + cfg = MagicMock() + cfg.default_agent = "operators-own-default" + cfg.agents = {"picked-agent": object()} + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", lambda: cfg) + from kiro_crew.providers.base import LLMProvider + + slot = _ChatSlot("test") + # Empty prior agent: the slot was running the CONFIGURED default before the switch. + slot.agent = "" + slot.project = "/Users/alice/proj" + busy = MagicMock(spec=LLMProvider) + busy.has_active_turn.side_effect = [False, True] + [True] * 8 + state = _mock_state(slot, provider=busy) + state.conversation_log = MagicMock() + state.sessions.reset = AsyncMock(return_value=True) + async with TestClient(TestServer(_make_app(state))) as client: + # NAMES an agent, so the empty-selection branch never runs. + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "picked-agent"}) + assert resp.status == 409 + armed = state.sessions.mark_retire_on_next_claim.call_args + assert armed is not None, "the rollback never armed an agent" + assert armed.kwargs["agent"] == "operators-own-default", ( + "a NAMED switch left default_alias empty, so the rollback armed the built-in " + f"instead of the configured default; got {armed.kwargs['agent']!r}" + ) + + @pytest.mark.asyncio + async def test_the_identity_arm_records_the_alias_not_a_resolved_snapshot(self, monkeypatch): + """The arm must name the ALIAS, whose target is resolved fresh at every consume. + + `slot.agent` is an alias and the config maps it to a runtime agent. Recording the + RESOLVED target freezes that mapping for the arm's whole lifetime, so an alias + re-pointed by a config edit during the window feeds the retirement retry the OLD + target -- and the retry then replaces a correctly-resolved session with one running + the wrong agent. Naming the alias keeps the arm stable and defers resolution. + """ + resolved = SimpleNamespace( + workspace="ws", memory_store="ms", kiro_agent="old-runtime-target" + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.resolve_agent_bindings", + lambda cfg, name=None, project=None: resolved, + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", lambda: MagicMock() + ) + slot = _ChatSlot("test") + slot.agent = "old-alias" + slot.project = "/Users/alice/proj" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-alias"}) + assert resp.status == 200 + armed = state.sessions.mark_retire_on_next_claim.call_args.kwargs["agent"] + assert armed == "new-alias", ( + "the arm froze a resolved target instead of the alias; a config re-point during " + f"the arm window then hands the retry a stale agent. armed={armed!r}" + ) + + @pytest.mark.asyncio + async def test_agent_switch_answers_503_when_the_workspace_is_unavailable(self, monkeypatch): + """A cleared slot whose default root cannot be resolved gets a controlled error. + + The resolution is the only filesystem work on this path and it is reached before + the commit, so a raise must answer a retryable 503 rather than escape as a 500 -- + and the slot must be left exactly as the request found it. + """ + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", _boom_cfg) + slot = _ChatSlot("test") + slot.agent = "old-agent" + state = _mock_state(slot, provider=None) + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=OSError("root unreadable")) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + data = await resp.json() + assert resp.status == 503 + assert data["code"] == "workspace_unavailable" + assert slot.agent == "old-agent" + state.sessions.reset.assert_not_awaited() + + @pytest.mark.asyncio + async def test_an_unavailable_workspace_answers_503_without_publishing_or_arming( + self, monkeypatch + ): + """The resolve runs BEFORE the commit, so its failure has nothing to undo. + + Resolving after publishing `slot.agent` required an unwind, and that unwind armed + the retirement -- which re-resolves the same cleared value SYNCHRONOUSLY, the very + resolution that had just failed. The raise escaped the handler, so an unavailable + workspace root answered an uncaught 500 instead of the retryable 503, and the arm + that was supposed to protect the recovery was what crashed it. Resolving first + removes the window instead of compensating for it: nothing is published, so there + is no session to protect and no arm to raise. + """ + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", _boom_cfg) + slot = _ChatSlot("test") + slot.agent = "old-agent" + state = _mock_state(slot, provider=None) + state.conversation_log = MagicMock() + + published: list[str] = [] + + async def _fail_resolve(key, cwd): + published.append(slot.agent) + raise OSError("root unreadable") + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_fail_resolve) + # The arm resolves its cwd synchronously, so on the old ordering it raised HERE. + state.sessions.mark_retire_on_next_claim = MagicMock(side_effect=OSError("root unreadable")) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + assert resp.status == 503, ( + "an unavailable workspace root must stay a retryable 503 -- a 500 here means " + "the recovery path re-ran the resolution that had already failed" + ) + assert (await resp.json())["code"] == "workspace_unavailable" + + assert published == ["old-agent"], ( + "the resolve must run BEFORE the agent is published, so a failure leaves the " + f"slot exactly as the request found it; the resolve saw {published}" + ) + assert slot.agent == "old-agent" + state.sessions.mark_retire_on_next_claim.assert_not_called() + + @pytest.mark.asyncio + async def test_an_empty_agent_selection_arms_the_defaults_alias(self, monkeypatch): + """An empty selection runs the DEFAULT agent, so the arm must name THAT alias. + + The arm degraded to a literal here, which the correctly-resolved claim never matches, + so the retry re-pointed onto the literal and ran an identity the user never chose. + Any client can send an empty selection and the regex gate lets it through. The arm + names the default ALIAS rather than its resolved target, because a target frozen at + arm time survives a config re-point and feeds the retry the old agent. + """ + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", + MagicMock(return_value=MagicMock(agents={}, default_agent="house-default")), + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.resolve_agent_bindings", + MagicMock(return_value=_bindings_for(kiro_agent="claude-code", workspace_dir=None)), + ) + slot = _ChatSlot("test") + slot.agent = "" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": ""}) + assert resp.status == 200 + armed_agent = state.sessions.mark_retire_on_next_claim.call_args.kwargs["agent"] + assert armed_agent == "house-default", ( + "an empty selection must arm the DEFAULT agent's ALIAS; a literal fallback is " + f"the mismatch that refuses the real claim, and a resolved target freezes the " + f"mapping the retry then re-points onto; got {armed_agent!r}" + ) + + @pytest.mark.asyncio + async def test_agent_switch_still_arms_the_resolved_default_when_cleared(self, monkeypatch): + """Positive control for the two tests above. + + A guard that skipped every resolution, or refused them all, would pass those two + and silently arm an empty target -- which is the stale-binding class the arm exists + to remove. A cleared slot must still resolve, and arm the resolved directory. + """ + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", _boom_cfg) + slot = _ChatSlot("test") + slot.agent = "old-agent" + # Cleared, not merely unscoped: without this the fixture models a slot that never had a + # project, which states no directory and is the case the sibling test above pins. + slot.project_cleared = True + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + assert resp.status == 200 + state.sessions.resolve_arm_cwd.assert_awaited() + armed = state.sessions.mark_retire_on_next_claim.call_args.args[1] + assert armed == "/workspace/_default" + + @pytest.mark.asyncio + async def test_agent_switch_never_arms_the_project_it_just_left(self, monkeypatch): + """A workspace with NO default project must not arm the OLD directory. + + ``default_project_dir`` answers "" when the workspace directory is missing or + sensitive, so the committed post-switch project is legitimately empty. The arm's + fallback then decides what the next cwd-less claim binds, and resolving it from the + PRE-switch project made that the directory being abandoned -- a silent bind to the + old repository, with relative writes landing there and nothing to recover from. + The correct fallback for an empty project is the CLEARED per-session default. + """ + cfg = MagicMock() + cfg.agents = {"new-agent": MagicMock()} + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", lambda: cfg) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.warm_project_agent_names", AsyncMock() + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.resolve_agent_bindings", + lambda *a, **kw: _bindings_for(kiro_agent="ka", workspace_dir="/ws/empty"), + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers._workspace_name_for_dir", lambda *a: "empty-ws" + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.cached_project_agent_names", lambda *a: frozenset() + ) + # The workspace has no default project: this is the condition under test. + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.default_project_dir", lambda *a: "") + slot = _ChatSlot("test") + slot.agent = "old-agent" + slot.project = "/Users/alice/OLD-project" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + assert resp.status == 200 + armed = state.sessions.mark_retire_on_next_claim.call_args.args[1] + assert armed != "/Users/alice/OLD-project" + assert armed == "/workspace/_default" + + @pytest.mark.asyncio + async def test_a_concurrent_clear_during_the_awaits_still_arms_a_resolved_path( + self, monkeypatch + ): + """An unlocked clear landing mid-request must not hand `""` to the SYNCHRONOUS arm. + + `slot.project` has writers that take no lock -- the in-turn set_project directive sets + it to `""` -- so a clear can land during this handler's resolution awaits. The arm reads + `slot.project or ` and runs synchronously inside the commit window, so an + unresolved fallback means `mark_retire_on_next_claim` receives the empty string and + resolves it itself: a mkdir and realpath of the workspace root, on the event loop, which + that method's own contract forbids. Gating the resolve on the two candidate projects + could not see this write, so the resolve is unconditional. + """ + slot = _ChatSlot("test") + slot.agent = "old-agent" + slot.project = "/Users/alice/proj" + state = _mock_state(slot, provider=None) + state.sessions.reset = AsyncMock(return_value=True) + state.conversation_log = MagicMock() + + async def _clear_mid_flight(*_a, **_kw): + slot.project = "" + # A real clear sets BOTH fields; emptying only the project models a slot that was + # never scoped, which must NOT arm the cleared default. + slot.project_cleared = True + + cfg = MagicMock() + cfg.agents = {"new-agent": MagicMock()} + monkeypatch.setattr("kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", lambda: cfg) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.resolve_agent_bindings", + lambda *a, **kw: _bindings_for(kiro_agent="ka", workspace_dir="/ws/x"), + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers._workspace_name_for_dir", lambda *a: "ws-x" + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.cached_project_agent_names", lambda *a: frozenset() + ) + # A real post-switch project, so the fallback is reached ONLY because the concurrent + # clear made the commit's compare-and-set lose and left `slot.project` empty. + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.default_project_dir", lambda *a: "/ws/x/proj" + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.warm_project_agent_names", _clear_mid_flight + ) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + assert resp.status == 200 + assert slot.project == "", "precondition: the concurrent clear must have survived" + armed = state.sessions.mark_retire_on_next_claim.call_args.args[1] + assert armed == "/workspace/_default" + assert armed != "" + + @pytest.mark.asyncio + async def test_agent_switch_sees_the_linked_sessions_active_turn(self): + # The busy probe lands on the live linked session: an in-flight + # channel turn answers 409 instead of tearing the turn (or a + # captured-identity session) down. + from kiro_crew.providers.acp import AcpProvider + + slot = _ChatSlot("test") + slot.agent = "old-agent" + slot.linked_session_key = "slack:123.456" + provider = MagicMock(spec=AcpProvider) + provider.has_active_turn.return_value = True + state = _mock_state(slot, provider=None) + state.sessions.get_provider = MagicMock( + side_effect=lambda key: provider if key == "slack:123.456" else None + ) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + data = await resp.json() + assert resp.status == 409 + assert data["code"] == "turn_in_flight" + assert slot.agent == "old-agent" + state.sessions.reset.assert_not_awaited() + + @pytest.mark.asyncio + async def test_app_caller_cannot_switch_a_linked_sessions_agent(self): + # Owning the slot is not owning the channel session it is bound to: + # denied as an indistinguishable 404, nothing mutated. + slot = _ChatSlot("test") + slot.agent = "old-agent" + slot._app = "demo-app" + slot.linked_session_key = "slack:123.456" + state = _mock_state(slot, provider=None) + async with TestClient(TestServer(_make_app_as(state, "demo-app"))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + assert resp.status == 404 + assert slot.agent == "old-agent" + state.sessions.reset.assert_not_awaited() + # The cleared-project resolution mkdirs and realpaths the workspace root, so + # an unauthorized caller must reach no filesystem work en route to its 404. + state.sessions.resolve_arm_cwd.assert_not_awaited() @pytest.mark.asyncio async def test_rebind_during_reset_rolls_back_the_agent_switch(self, monkeypatch): @@ -1776,6 +2840,75 @@ async def _reset_concurrent_write_and_rebind(*_a, **_k): # The concurrent writer's value survives; only OUR commit unwinds. assert slot.agent == "new-agent" + @pytest.mark.asyncio + async def test_the_rollback_arms_the_preserved_agent_not_the_prior_one(self, monkeypatch): + """A preserved concurrent write owns the slot, so the arm must name ITS identity. + + The rollback stands down on token identity, so an unlocked writer's agent + survives this request's unwind -- and the rollback path is reached precisely + BECAUSE a turn is in flight, which is exactly when an in-turn ``/agent`` + directive lands. The arm raised before the awaits still names the abandoned + switch, so it must be re-pointed; re-pointing it at the PRIOR agent arms an + identity the slot has left, so the next cwd-less claim is refused for + the wrong agent and the retry re-points the session to an agent nobody + selected. The registration matches on ``kiro_agent or slot.agent``, so the + armed value has to be the preserved agent's RESOLVED target. + """ + mock_cfg = MagicMock() + mock_cfg.agents = {} + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", lambda: mock_cfg + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.warm_project_agent_names", AsyncMock() + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.cached_project_agent_names", + lambda p: frozenset(), + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers._workspace_name_for_dir", + lambda cfg, ws_dir: "ws1", + ) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.default_project_dir", + lambda ws: None, + ) + targets = { + "old-agent": "old-target", + "hijack-agent": "hijack-target", + "new-agent": "new-target", + } + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.resolve_agent_bindings", + lambda cfg, name, project_dir=None: MagicMock( + kiro_agent=targets.get(str(name)), workspace_dir="/tmp/ws1" + ), + ) + slot = _ChatSlot("test") + slot.agent = "old-agent" + state = _mock_state(slot, provider=None) + state.conversation_log = MagicMock() + + async def _hijack_then_rebind(*_a, **_k): + # A DIFFERENT agent, so the rollback PRESERVES it instead of restoring. + slot.agent = "hijack-agent" + slot.linked_session_key = "cron:job-1" + return True + + state.sessions.reset = AsyncMock(side_effect=_hijack_then_rebind) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + assert resp.status == 409 + assert slot.agent == "hijack-agent" + armed = list(state.sessions.mark_retire_on_next_claim.call_args_list) + assert armed, "the rollback must re-point the arm it raised before the awaits" + got = armed[-1].kwargs.get("agent") + assert got == "hijack-agent", ( + "the arm must name the PRESERVED agent's ALIAS, not the prior agent and not a " + f"resolved target frozen at arm time -- got {got!r}" + ) + @pytest.mark.asyncio async def test_concurrent_same_project_write_survives_the_rollback(self, monkeypatch): # The in-turn set_project directive can write the VERY project this @@ -1832,20 +2965,20 @@ async def _reset_concurrent_project_write_and_rebind(*_a, **_k): assert slot.agent == "old-agent" @pytest.mark.asyncio - async def test_rebind_during_project_save_rolls_back_and_answers_409( + async def test_rebind_during_the_save_transfers_the_arm_to_the_live_key( self, tmp_path, monkeypatch ): - # A binding that lands while the recent-project save awaits means the - # deferred-reset flag would name a session the slot does not run on - # (and the flag's consumer would tear down a session nobody is on - # while the actual session keeps the old CWD): the commit is rolled - # back, the flag stays unarmed, and the caller retries against the - # current binding. + # The save awaits AFTER the arm, so a binding landing there leaves the arm and + # pending key on the abandoned key while the key it now runs on is unarmed. import os slot = _ChatSlot("test") slot.project = "/workspace/old-ws" state = _mock_state(slot) + transfers: list = [] + state.sessions.transfer_retire_arm = MagicMock( + side_effect=lambda frm, to, cwd: transfers.append((frm, to)) + ) def _save_and_rebind(_project): slot.linked_session_key = "cron:job-1" @@ -1854,6 +2987,71 @@ def _save_and_rebind(_project): "kiro_crew.dashboard.chat_handlers._save_recent_project", _save_and_rebind ) new_dir = os.path.realpath(str(tmp_path)) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/project", json={"project": new_dir}) + assert resp.status == 200 + + live = effective_session_key(slot) + assert slot._pending_reset_history_key == live, ( + "the pending reset still names the abandoned key, so the reset lands on a " + "session nobody is on while the live key keeps the old directory" + ) + assert ( + transfers and transfers[-1][1] == live + ), "the arm was left on the abandoned key, so the live key runs unarmed" + + @pytest.mark.asyncio + async def test_arm_is_raised_before_the_recent_project_save_yields(self, tmp_path, monkeypatch): + # The recent-project save is disk I/O, so it yields. A cwd-less claim acquiring + # there reads the NEW project; with no arm up it reuses the old session. + import os + + slot = _ChatSlot("test") + slot.project = "/workspace/old-ws" + state = _mock_state(slot) + armed: list = [] + state.sessions.mark_retire_on_next_claim = MagicMock( + side_effect=lambda key, cwd, agent=None: armed.append(cwd) + ) + observed: dict = {} + + def _save_and_observe(_project): + # Whatever a competing claim could read, it reads HERE. + observed["project"] = str(slot.project) + observed["armed"] = list(armed) + + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers._save_recent_project", _save_and_observe + ) + new_dir = os.path.realpath(str(tmp_path)) + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/project", json={"project": new_dir}) + assert resp.status == 200 + + assert observed, "the save never ran, so the window was never observed" + assert not (observed["project"] == new_dir and not observed["armed"]), ( + "the slot advertised the new project while the arm was still unraised, so a " + "cwd-less claim in this window reuses the old session under the old directory" + ) + + @pytest.mark.asyncio + async def test_rebind_during_arm_resolve_answers_409_without_mutating( + self, tmp_path, monkeypatch + ): + # Same intent at the window that now exists: the arm resolve is the only await + # before the commit, so a binding landing there must leave the slot untouched. + import os + + slot = _ChatSlot("test") + slot.project = "/workspace/old-ws" + state = _mock_state(slot) + + def _resolve_and_rebind(key, cwd): + slot.linked_session_key = "cron:job-1" + return cwd or "/workspace/_default" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_resolve_and_rebind) + new_dir = os.path.realpath(str(tmp_path)) async with TestClient(TestServer(_make_app(state))) as client: resp = await client.post("/api/chat/slots/test/project", json={"project": new_dir}) data = await resp.json() @@ -2471,3 +3669,414 @@ async def test_slot_with_attached_subagents_is_skipped_even_when_forced(self): assert data["switched"] == [] assert slot.model == _MODEL_A state.sessions.reset.assert_not_awaited() + + +class TestTheSettleRefusesAnUnsettledKey: + """A rebind after the LAST resolve must not arm the key the slot just left. + + The settle probes before each resolve. Without a verification after the final one, a rebind + landing in that window is invisible: the caller arms the abandoned string key, and a later + session under that same key reads an arm naming a directory chosen for a binding that is + gone. Reported rather than assumed away -- an unsettled key publishes nothing. + """ + + @pytest.mark.asyncio + async def test_a_rebind_after_the_final_pass_publishes_no_arm(self, tmp_path): + from kiro_crew.dashboard.chat_runner import _settle_arm_target + + slot = _ChatSlot("test") + state = _mock_state(slot, provider=None) + + # Every probe reports a DIFFERENT key, so the passes can never settle -- which is the + # shape of a rebind that keeps landing, including one after the last resolve. + keys = iter(f"chat-{n}" for n in range(1, 40)) + with patch( + "kiro_crew.dashboard.chat_runner.effective_session_key", + side_effect=lambda _slot: next(keys), + ): + denied, key, cwd, settled = await _settle_arm_target(state, slot, "chat-0", "", None) + + assert denied is None + assert settled is False, ( + "the settle reported success on a key that never stabilised, so its caller arms " + "an abandoned key and a later session under that string reads the stale arm" + ) + state.sessions.supersede_arm_for_new_slot.assert_not_called() + + @pytest.mark.asyncio + async def test_an_unsettled_resolve_keeps_an_arm_this_caller_did_not_write(self, tmp_path): + """A caller holding no generation must not spend a project arm another producer owns. + + Retracting unconditionally erased that arm, and the next claim stating no directory -- + every channel turn states none -- was then served the superseded project's session with + nothing left to retire it. + """ + from kiro_crew.dashboard.chat_runner import _settle_arm_target + + slot = _ChatSlot("test") + state = _mock_state(slot, provider=None) + + keys = iter(f"chat-{n}" for n in range(1, 40)) + with patch( + "kiro_crew.dashboard.chat_runner.effective_session_key", + side_effect=lambda _slot: next(keys), + ): + await _settle_arm_target(state, slot, "chat-0", "", None, only_generation=None) + + state.sessions.supersede_arm_for_new_slot.assert_not_called() + + @pytest.mark.asyncio + async def test_an_unsettled_resolve_retracts_the_generation_this_caller_owns(self, tmp_path): + """A producer unwinding its OWN arm still retracts it, scoped to its own generation.""" + from kiro_crew.dashboard.chat_runner import _settle_arm_target + + slot = _ChatSlot("test") + state = _mock_state(slot, provider=None) + + keys = iter(f"chat-{n}" for n in range(1, 40)) + with patch( + "kiro_crew.dashboard.chat_runner.effective_session_key", + side_effect=lambda _slot: next(keys), + ): + await _settle_arm_target(state, slot, "chat-0", "", None, only_generation=7) + + state.sessions.supersede_arm_for_new_slot.assert_called_once_with( + "chat-0", only_generation=7 + ) + + @pytest.mark.asyncio + async def test_an_unset_project_arms_no_directory_rather_than_the_cleared_default(self): + """An UNSET project must not be armed as if it had been CLEARED. + + Both leave `slot.project` empty, so truthiness cannot tell them apart -- only + `project_cleared` does, which is why the arm is keyed on `claim_cwd`. Resolving the + cleared default for a slot that was never scoped arms the per-session workspace root + and so DEFEATS the stored-cwd resume override the unset case exists to preserve: the + next turn's relative writes land somewhere other than the directory it resumed. + """ + from kiro_crew.dashboard.chat_runner import _settle_arm_target + + slot = _ChatSlot("test") + # Never scoped: empty project, and NOT cleared -- the state this test is about. + slot.project = "" + slot.project_cleared = False + state = _mock_state(slot, provider=None) + state.sessions.resolve_arm_cwd = AsyncMock(return_value="/resolved/cleared/default") + + denied, key, cwd, settled = await _settle_arm_target( + state, slot, "chat-0", slot.claim_cwd, None + ) + + assert denied is None + assert settled is True + assert cwd is None, f"an unset project must state no directory; armed {cwd!r}" + state.sessions.resolve_arm_cwd.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_cleared_project_still_arms_the_resolved_default(self): + """The sibling case must keep working: a real clear DOES take the default.""" + from kiro_crew.dashboard.chat_runner import _settle_arm_target + + slot = _ChatSlot("test") + slot.project = "" + slot.project_cleared = True + state = _mock_state(slot, provider=None) + state.sessions.resolve_arm_cwd = AsyncMock(return_value="/resolved/cleared/default") + + denied, key, cwd, settled = await _settle_arm_target( + state, slot, "chat-0", slot.claim_cwd, None + ) + + assert denied is None + assert cwd == "/resolved/cleared/default", f"a clear must resolve the default; got {cwd!r}" + + @pytest.mark.asyncio + async def test_a_stable_key_still_settles(self, tmp_path): + from kiro_crew.dashboard.chat_runner import _settle_arm_target + + slot = _ChatSlot("test") + slot.project = str(tmp_path) + state = _mock_state(slot, provider=None) + + denied, key, cwd, settled = await _settle_arm_target( + state, slot, "chat-0", str(tmp_path), None + ) + assert denied is None + assert settled is True + + +class TestTheCommitToArmWindowHoldsByConstruction: + """No ``await`` may separate a binding commit from the arm that protects it. + + The window is what makes the arm safe: a claim landing between the published triple and the + arm reads the new bindings with nothing raised to retire the session bound to the old ones. + Until now that was carried by a comment, so a later edit could open the window silently and + every test would still pass. This reads the source instead, so the invariant fails at the + seam that breaks it rather than in production. + """ + + COMMIT = "_CommitToken" + ARMS = frozenset({"mark_retire_on_next_claim", "transfer_retire_arm", "note_project_change"}) + + @staticmethod + def _called_name(node: ast.AST) -> str | None: + call = node.value if isinstance(node, ast.Await) else node + if isinstance(call, ast.Expr): + call = call.value + if isinstance(call, ast.Await): + call = call.value + if isinstance(call, ast.Assign): + call = call.value + if isinstance(call, ast.Await): + call = call.value + if isinstance(call, ast.Call) and isinstance(call.func, ast.Attribute): + return call.func.attr + return None + + @classmethod + def _commits_a_binding(cls, stmt: ast.stmt) -> bool: + return any( + isinstance(n, ast.Call) and isinstance(n.func, ast.Name) and n.func.id == cls.COMMIT + for n in ast.walk(stmt) + ) + + @classmethod + def _publishes_an_arm(cls, stmt: ast.stmt) -> bool: + return cls._called_name(stmt) in cls.ARMS + + @classmethod + def _violations(cls, source: str) -> list[str]: + """Every block where an await separates a binding commit from a later arm.""" + found: list[str] = [] + for node in ast.walk(ast.parse(source)): + for field in ("body", "orelse", "finalbody"): + block = getattr(node, field, None) + if not isinstance(block, list): + continue + commits = [i for i, s in enumerate(block) if cls._commits_a_binding(s)] + arms = [i for i, s in enumerate(block) if cls._publishes_an_arm(s)] + if not commits or not arms or max(arms) < min(commits): + continue + for stmt in block[min(commits) : max(arms) + 1]: + if cls._publishes_an_arm(stmt): + continue + for inner in ast.walk(stmt): + if isinstance(inner, ast.Await): + name = cls._called_name(inner) or "" + found.append(f"line {inner.lineno}: await {name}") + return found + + def test_no_await_separates_a_commit_from_its_arm(self): + source = Path(chat_handlers.__file__).read_text(encoding="utf-8") + assert self._violations(source) == [], ( + "an await was added between a published binding triple and the arm that protects " + "it, so a claim in that window runs the new bindings with the old session resident" + ) + + def test_the_guard_fails_when_the_window_is_opened(self): + """Positive control: the scan must reject the very edit the invariant forbids.""" + opened = ( + "async def h(state, slot):\n" + " slot.agent = _CommitToken('a')\n" + " await state.sessions.resolve_arm_cwd('k', '')\n" + " state.sessions.mark_retire_on_next_claim('k', None)\n" + ) + assert self._violations(opened), "the scan cannot see an await inside the window" + + closed = ( + "async def h(state, slot):\n" + " slot.agent = _CommitToken('a')\n" + " state.sessions.mark_retire_on_next_claim('k', None)\n" + ) + assert self._violations(closed) == [], "the scan flags a window that is actually closed" + + def test_the_scan_reads_utf8_on_every_platform(self): + """Control: the module holds bytes cp1252 cannot decode, so the encoding is load-bearing. + + ``read_text()`` with no encoding takes the platform default -- cp1252 on Windows -- so + this scan died there on a byte the source legitimately contains while every Linux shard + passed. Reading the same file as cp1252 must still raise, otherwise this assertion would + hold only because the host happens to be UTF-8 and the Windows break would return. + """ + path = Path(chat_handlers.__file__) + with pytest.raises(UnicodeDecodeError): + path.read_text(encoding="cp1252") + assert path.read_text(encoding="utf-8"), "the utf-8 read must succeed on every platform" + + def test_an_awaited_arm_is_not_itself_a_violation(self): + """``note_project_change`` IS awaited, so the arm's own await must not read as the leak.""" + awaited_arm = ( + "async def h(state, slot):\n" + " slot.project = _CommitToken('p')\n" + " await state.sessions.note_project_change('k', '/p')\n" + ) + assert self._violations(awaited_arm) == [] + + +class TestTheAgentSwitchAuthorizesBeforeItPublishes: + """The arm target must be settled and authorized BEFORE the binding triple is published. + + The resolve inside the settle awaits. If the triple is committed first, then for that whole + window `slot.agent` names the new agent while the arm still names the key the slot left, so a + turn claiming the rebound key runs an agent whose switch may still answer 409 and roll back -- + and `mark_retire_on_next_claim` states an in-flight turn is never retro-corrected. Rolling back + afterwards does not close that window; only ordering the gate first does. + """ + + def test_the_settle_and_gate_precede_the_commit_triple(self): + import inspect + + src = inspect.getsource(chat_handlers.api_chat_slot_agent) + + settle = src.find("await _settle_arm_target(") + gate = src.find("if pre_commit_denied is not None:") + publish = src.find("slot.agent = _CommitToken(agent_name)") + + assert settle != -1, "the pre-commit settle is gone" + assert gate != -1, "the pre-commit refusal is gone" + assert publish != -1, "the commit triple moved; re-point this control" + assert settle < publish, ( + "the binding triple is published before the arm target is settled, so a turn on the " + "rebound key can run an agent whose switch has not been authorized" + ) + assert gate < publish, ( + "the rebind refusal runs after the commit, so an unauthorized agent is published " + "first and only rolled back afterwards -- an in-flight turn already saw it" + ) + + def test_the_arm_transfer_follows_the_commit_with_no_await_between(self): + """Commit and arm must share one suspension-free window.""" + import inspect + + src = inspect.getsource(chat_handlers.api_chat_slot_agent) + publish = src.find("slot.agent = _CommitToken(agent_name)") + transfer = src.find("state.sessions.transfer_retire_arm(") + assert publish != -1 and transfer != -1 + between = src[publish:transfer] + assert "await " not in between, ( + "an await sits between the commit and its arm transfer, so a claim can read the " + f"published triple while the arm names the old key: {between[:160]!r}" + ) + + +class TestAnUnsettledKeyCommitsNoBinding: + """The commit and the arm are one unit, so an unsettled key must publish neither. + + `_settle_arm_target` reports `arm_settled` False when the slot kept rebinding through + every pass. The arm is then owed to a binding nobody is on, so the transfer is skipped -- + and committing the agent and project anyway publishes a new binding on the live key with + no arm raised to protect it. + """ + + @pytest.mark.asyncio + async def test_an_unsettled_settle_answers_409_and_leaves_the_agent_alone(self): + slot = _ChatSlot("test") + slot.agent = "old-agent" + slot.project = "/workspace/old" + state = _mock_state(slot) + state.conversation_log = MagicMock() + + armed: list = [] + state.sessions.mark_retire_on_next_claim = MagicMock( + side_effect=lambda *a, **k: armed.append((a, k)) or 1 + ) + + # The settle exhausts its passes: denial None, but the key never settled. + async def never_settles(*args, **kwargs): + return None, "slack:moved-9999", "/workspace/old", False + + with ( + patch.object(chat_handlers, "_settle_arm_target", new=never_settles), + patch.object(chat_handlers, "warm_project_agent_names", new=AsyncMock()), + ): + app = _make_app(state) + async with TestClient(TestServer(app)) as client: + resp = await client.post("/api/chat/slots/test/agent", json={"agent": "new-agent"}) + + assert resp.status == 409, await resp.text() + assert str(slot.agent) == "old-agent", ( + "the switch committed a new agent on a key that never settled, so the binding is " + f"published with no arm to protect it; agent={slot.agent!r}" + ) + assert ( + armed == [] + ), f"an arm was raised for an unsettled key, which nobody is on; armed={armed!r}" + + +class TestARejectedSwitchLeavesANeverScopedSlotUnscoped: + """A rejected switch must not convert an UNSET project into an explicit clear. + + `slot.project or cleared_arm_cwd` collapsed two distinct prior states. A slot that + was explicitly CLEARED states `CWD_CLEARED`, whose arm is the per-session default. + A slot that was NEVER SCOPED states nothing, which is what keeps the warm pool and + its stored-cwd resume override; arming the resolved default there binds the + per-session scratch directory instead, so the next turn's relative writes land + outside the directory the session was resuming. + """ + + @pytest.mark.asyncio + async def test_a_never_scoped_slot_arms_no_directory_but_still_retires_the_agent(self): + slot = _ChatSlot(key="chat-1", agent="old-agent") + # NEVER SCOPED: no project, and no clear ever asked for either. + slot.project = "" + slot.project_cleared = False + # The concurrent turn starts INSIDE the post-commit settle await, which is the + # window the pre-commit guard cannot see and the rollback path exists for. + running_task = MagicMock() + running_task.done.return_value = False + + async def settle_then_turn_starts(*args, **kwargs): + slot.task = running_task + # The helper's own shape: denial, key, and whether the transfer settled. + return None, None, True + + state = _mock_state(slot) + state.conversation_log = MagicMock() + + # The directory a CLEARED slot would arm, and the one an unset slot must not. + scratch = "/workspace/_default" + armed: list = [] + + def record(key, cwd, agent=None): + armed.append({"key": key, "cwd": cwd, "agent": agent}) + return 1 + + state.sessions.mark_retire_on_next_claim = MagicMock(side_effect=record) + + with ( + patch.object(chat_handlers, "warm_project_agent_names", new=AsyncMock()), + patch.object( + chat_handlers, + "_settle_and_transfer_arm", + new=AsyncMock(side_effect=settle_then_turn_starts), + ), + ): + app = _make_app(state) + async with TestClient(TestServer(app)) as client: + resp = await client.post( + "/api/chat/slots/chat-1/agent", json={"agent": "new-agent"} + ) + + assert resp.status == 409, await resp.text() + assert slot.project == "", "precondition: the rollback did not restore the unset project" + assert not getattr( + slot, "project_cleared", False + ), "precondition: the rollback left the unset project marked as an explicit clear" + assert ( + len(armed) >= 2 + ), f"precondition: the rejected switch raised no rollback arm; arms={armed!r}" + + arm = armed[-1] + assert arm["cwd"] != scratch, ( + "the rollback armed the per-session scratch directory for a slot that was never " + f"scoped, so the next turn writes relative paths there; armed={arm['cwd']!r}" + ) + assert arm["cwd"] is None, ( + "an unset project states NO directory, so the arm must state none either; " + f"armed={arm['cwd']!r}" + ) + # Retirement itself must survive the fix: the agent still has to be retired. + assert arm["agent"], ( + "the rollback lost agent retirement, so the next claim is served a session " + "still running the rejected agent" + ) diff --git a/test/test_chat_slot_unsettled_publish.py b/test/test_chat_slot_unsettled_publish.py new file mode 100644 index 00000000000..13546eac9ed --- /dev/null +++ b/test/test_chat_slot_unsettled_publish.py @@ -0,0 +1,196 @@ +"""An unsettled arm target may not leave a binding published. + +``_settle_arm_target`` reports whether the key it resolved SETTLED, and retracts the source +arm when it did not. A caller that publishes anyway leaves a workspace or project visible on +the slot with no arm raised to carry it, so a later cwd-less claim binds the directory the +slot is leaving and every relative write in that turn lands in the wrong project. The caller +must therefore publish nothing and answer 409, and unwind whatever it had already committed. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer + +from kiro_crew.dashboard import chat_handlers +from kiro_crew.dashboard.chat_handlers import api_chat_slot_project, api_chat_slot_workspace +from kiro_crew.dashboard.chat_utils import bind_linked_session_key +from kiro_crew.dashboard.state import DashboardState, _ChatSlot + + +def _make_app(state: DashboardState) -> web.Application: + app = web.Application() + app["state"] = state + + @web.middleware + async def dashboard_auth_marker(request, handler): + request["user"] = "dashboard" + return await handler(request) + + app.middlewares.append(dashboard_auth_marker) + app.router.add_post("/api/chat/slots/{slot}/workspace", api_chat_slot_workspace) + app.router.add_post("/api/chat/slots/{slot}/project", api_chat_slot_project) + return app + + +def _mock_state(slot: _ChatSlot) -> DashboardState: + state = MagicMock(spec=DashboardState) + state._slots = {slot.key: slot} + state.push_slots_update = MagicMock() + state.broadcast_context_usage = MagicMock() + state.sessions = MagicMock() + state.sessions.reset = AsyncMock(return_value=True) + state.sessions.note_project_change = AsyncMock() + state.sessions.get_provider = MagicMock(return_value=None) + state.sessions.transfer_retire_arm = MagicMock() + state.sessions.supersede_arm_for_new_slot = MagicMock() + state.sessions.mark_retire_on_next_claim = MagicMock(return_value=1) + state.conversation_log = MagicMock() + return state + + +class TestAnUnsettledArmPublishesNoWorkspace: + @pytest.mark.asyncio + async def test_a_parked_rebind_during_the_settle_commits_nothing(self, monkeypatch): + """The switch must not reach its reset, because nothing may be published first. + + A rebind arriving inside the settle's own region is PARKED, so the key the settle + observed never moved and it reports unsettled while retracting the source arm. The + binding pair is committed before the reset by design, so publishing on an unsettled + key exposes a workspace and project no transfer will ever carry. The 409 alone does + not discriminate -- the later rebind guard answers that too, once the parked key is + applied -- so what this pins is that NOTHING was published and no session torn down. + """ + slot = _ChatSlot("test") + slot.workspace = "old-ws" + # Empty so the settle RESOLVES rather than short-circuiting on a stated project -- + # that resolve is the only await inside the region, so it is where a rebind can park. + slot.project = "" + slot.linked_session_key = "slack:1111.0001" + state = _mock_state(slot) + monkeypatch.setattr(chat_handlers, "default_project_dir", lambda ws: "/workspace/new-ws") + + async def _park_a_rebind(key, cwd): + bind_linked_session_key(slot, "cron:job-9") + return "/workspace/cleared" + + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=_park_a_rebind) + + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post("/api/chat/slots/test/workspace", json={"workspace": "new-ws"}) + body = await resp.json() + + assert resp.status == 409 + assert body["code"] == "session_rebound" + assert state.sessions.reset.await_count == 0, ( + "the switch tore down a session after an unsettled settle, so the bindings it " + "published were visible with no arm raised to carry them" + ) + assert ( + slot.workspace == "old-ws" + ), f"the workspace was published on an unsettled key; got {slot.workspace!r}" + assert ( + slot.project == "" + ), f"the project was published on an unsettled key; got {slot.project!r}" + + +class TestAnUnsettledArmPublishesNoProject: + @pytest.mark.asyncio + async def test_an_unsettled_transfer_unwinds_the_committed_project(self, tmp_path): + """The published project must be unwound, not merely left without its reset. + + This branch already retracts the deferred reset, which is what proves the key is + gone -- and that is exactly why the project committed before the transfer may not + stand: a cwd-less claim on the session the slot moved to would bind the directory + the slot left. Re-marked dirty because the save above awaits, so the periodic flush + may already have written the provisional project to disk. + """ + new_project = tmp_path / "new" + new_project.mkdir() + slot = _ChatSlot("test") + slot.project = "/old/project" + slot.project_cleared = False + slot.linked_session_key = "slack:1111.0001" + slot._dirty = False + state = _mock_state(slot) + state.sessions.resolve_arm_cwd = AsyncMock(return_value="/workspace/cleared") + + # Runs in a worker thread, as the real save does, and rebinds the slot there: that + # is the window the handler's own comment names as the one a rebind lands in. + def _rebind_during_the_save(_project): + slot.linked_session_key = "cron:job-9" + + # Unsettled, reported by the helper the handler calls: the branch under test is the + # one the helper's own retraction leaves behind. + async def _unsettled(*_a, **_k): + return None, "cron:job-9", "/workspace/cleared", False + + with ( + patch.object(chat_handlers, "_save_recent_project", _rebind_during_the_save), + patch.object(chat_handlers, "_settle_arm_target", AsyncMock(side_effect=_unsettled)), + patch.object(chat_handlers, "schedule_eager_spawn", MagicMock()), + ): + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/project", json={"project": str(new_project)} + ) + body = await resp.json() + + assert resp.status == 409, ( + "an unsettled transfer answered success, so the caller believes the project " + f"binding took; got {resp.status} {body}" + ) + assert body["code"] == "session_rebound" + assert slot.project == "/old/project", ( + "the project stayed published after the settle retracted its arm, so a cwd-less " + f"claim binds the directory the slot left; got {slot.project!r}" + ) + assert slot.project_cleared is False + assert slot._pending_reset_history_key is None + assert slot._dirty is True, ( + "the rollback was not re-marked dirty, so a flush that persisted the provisional " + "project leaves the rejected value on disk across a restart" + ) + + @pytest.mark.asyncio + async def test_a_cleared_slot_comes_back_cleared(self, tmp_path): + """The marker is unwound WITH the project, or a cleared slot reads as never-set. + + The commit retires the marker as it publishes a project, so restoring the path alone + leaves an empty project without the flag that distinguishes "deliberately cleared" + from "never had one" -- and the resume then restores the project the user cleared. + """ + new_project = tmp_path / "new" + new_project.mkdir() + slot = _ChatSlot("test") + slot.project = "" + slot.project_cleared = True + slot.linked_session_key = "slack:1111.0001" + state = _mock_state(slot) + state.sessions.resolve_arm_cwd = AsyncMock(return_value="/workspace/cleared") + + def _rebind_during_the_save(_project): + slot.linked_session_key = "cron:job-9" + + async def _unsettled(*_a, **_k): + return None, "cron:job-9", "/workspace/cleared", False + + with ( + patch.object(chat_handlers, "_save_recent_project", _rebind_during_the_save), + patch.object(chat_handlers, "_settle_arm_target", AsyncMock(side_effect=_unsettled)), + patch.object(chat_handlers, "schedule_eager_spawn", MagicMock()), + ): + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/project", json={"project": str(new_project)} + ) + + assert resp.status == 409 + assert slot.project == "" + assert slot.project_cleared is True, ( + "the cleared marker was not unwound with the project, so the empty project reads " + "as never-set and the resume restores the one the user cleared" + ) diff --git a/test/test_chat_slot_unsettled_reset.py b/test/test_chat_slot_unsettled_reset.py new file mode 100644 index 00000000000..b9bf017e630 --- /dev/null +++ b/test/test_chat_slot_unsettled_reset.py @@ -0,0 +1,89 @@ +"""A deferred reset may not be applied against a key whose arm transfer never settled. + +The consume path compares the key it armed against the key the settle reports. An UNSETTLED +settle reports the key it last observed -- which is the armed one when the rebind that stopped +it settling was PARKED rather than applied -- so key equality alone reads as "no rebind". The +reset then tears down the session the slot is leaving while the session it moves to keeps the +project it was supposed to lose. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from kiro_crew.dashboard.chat_runner import _consume_pending_reset +from kiro_crew.dashboard.chat_utils import bind_linked_session_key +from kiro_crew.dashboard.state import _ChatSlot + + +def _state_for(slot): + state = MagicMock() + state._slots = {slot.key: slot} + state.sessions = MagicMock() + state.sessions.reset = AsyncMock(return_value=True) + state.sessions.supersede_arm_for_new_slot = MagicMock() + state.sessions.transfer_retire_arm = MagicMock() + state.conversation_log = MagicMock() + return state + + +class TestAnUnsettledTransferDoesNotConsumeTheReset: + @pytest.mark.asyncio + async def test_a_parked_rebind_leaves_the_reset_armed_for_the_live_key(self): + """The reproduction: the settle reports the ARMED key while reporting unsettled.""" + slot = _ChatSlot("test") + slot.linked_session_key = "slack:1111.0001" + # CLEARED, which is the only state whose settle awaits at all: a stated project and an + # unset one are both answered synchronously, so no rebind can land inside their region. + slot.project = "" + slot.project_cleared = True + slot._pending_reset_history_key = "slack:1111.0001" + + state = _state_for(slot) + + # A rebind reaches the slot inside the settle's own region, so it is PARKED: the key + # the settle observes never moves, and the settle reports unsettled instead. + async def resolve(key, cleared): + bind_linked_session_key(slot, "cron:job-9") + return "/workspace/new" + + state.sessions.resolve_arm_cwd = resolve + + with patch("kiro_crew.dashboard.chat_runner._arm_pending_reset_retry") as rearm: + torn_down = await _consume_pending_reset(state, slot, allow_discard=False) + + assert state.sessions.reset.await_count == 0, ( + "the reset was applied against a key whose transfer never settled, tearing down " + "the session the slot is leaving while its live session keeps the old project; " + f"reset called with {state.sessions.reset.await_args}" + ) + assert not torn_down, "a teardown was reported for an unsettled transfer" + assert rearm.called, ( + "the reset was neither applied nor re-armed, so the project change is lost " + "entirely rather than deferred" + ) + assert slot._pending_reset_history_key == "cron:job-9", ( + "the flag was left on the abandoned key, so the retry repeats the same mistake; " + f"flag={slot._pending_reset_history_key!r}" + ) + + @pytest.mark.asyncio + async def test_a_settled_transfer_still_applies_the_reset(self): + """The refusal must be the unsettled transfer, not every deferred reset.""" + slot = _ChatSlot("test") + slot.linked_session_key = "slack:1111.0001" + slot.project = "/workspace/new" + slot._pending_reset_history_key = "slack:1111.0001" + + state = _state_for(slot) + state.sessions.resolve_arm_cwd = AsyncMock(return_value="/workspace/new") + + with patch("kiro_crew.dashboard.chat_runner.subagents_attached", return_value=False): + await _consume_pending_reset(state, slot, allow_discard=False) + + assert ( + state.sessions.reset.await_count == 1 + ), "a settled transfer failed to apply its reset, so no project change ever lands" + assert state.sessions.reset.await_args[0][0] == "slack:1111.0001" diff --git a/test/test_cli_server_more_coverage.py b/test/test_cli_server_more_coverage.py index fb365424163..8377bf51415 100644 --- a/test/test_cli_server_more_coverage.py +++ b/test/test_cli_server_more_coverage.py @@ -934,7 +934,13 @@ def taskrunner_env(monkeypatch, tmp_path): """Replace every collaborator ``_run_task`` constructs, and expose the spies.""" from kiro_crew.config import KiroCrewConfig - state: dict = {"vector": None, "sessions": None, "runner_kwargs": None, "observed": []} + state: dict = { + "vector": None, + "sessions": None, + "runner_kwargs": None, + "observed": [], + "cwd_keys": [], + } monkeypatch.setattr(KiroCrewConfig, "load", classmethod(lambda cls: cls())) monkeypatch.setattr(cli_server, "KiroCrewConfig", KiroCrewConfig) @@ -962,7 +968,12 @@ def _vector(**kw): monkeypatch.setattr( cli_server, "register_skill_read_observer", lambda ctx: state["observed"].append(ctx) ) - monkeypatch.setattr(cli_server, "_session_work_dir", lambda key: tmp_path) + + def _session_cwd(key: str) -> Path: + state["cwd_keys"].append(key) + return tmp_path + + monkeypatch.setattr(cli_server, "session_default_cwd", _session_cwd) monkeypatch.setattr(cli_server, "make_sync_embed_fn", lambda: (lambda text: [0.0])) monkeypatch.setattr(cli_server, "model_file_present", lambda: True) monkeypatch.setattr(cli_server, "store_embedding_space_is_stale", lambda vs: False) @@ -1011,6 +1022,10 @@ def test_completed_task_wires_runner_and_closes_sessions( assert kw["auto_test"] is False # --no-test inverts into auto_test assert kw["fresh"] is False assert kw["global_timeout"] == 90.0 + # The runner binds the PER-SESSION default, asked for under the taskrunner's own + # key -- a shared workspace default would answer a directory no provider binds. + assert kw["work_dir"] == tmp_path + assert taskrunner_env["cwd_keys"] == ["taskrunner:main"] assert taskrunner_env["ran"] == (spec.resolve(), "my-run") assert taskrunner_env["sessions"].pool_started is True assert taskrunner_env["sessions"].closed is True diff --git a/test/test_dashboard_approval.py b/test/test_dashboard_approval.py index 1117f46b43c..3d239582cda 100644 --- a/test/test_dashboard_approval.py +++ b/test/test_dashboard_approval.py @@ -161,6 +161,9 @@ def _make_state( sessions.get_or_create = AsyncMock(return_value=(client, True, False)) sessions.record_failure = AsyncMock() sessions.check_context_usage = MagicMock() + # Resolve-only, so it answers a PATH: an AsyncMock default would arm a MagicMock. + sessions.resolve_arm_cwd = AsyncMock(side_effect=lambda key, cwd: cwd or "/w/_default") + sessions.note_project_change = AsyncMock() state = DashboardState( sessions=sessions, crons=MagicMock( @@ -1697,6 +1700,12 @@ class TestPendingProjectReset: @pytest.mark.asyncio async def test_start_of_turn_resets_before_get_or_create(self, tmp_path): + """The deferred reset refuses on an ACTIVE TURN, never on a lifetime lease alone. + + `refuse_only_on_active_turn` is part of the pinned call shape: a member holding only + its lifetime lease is idle, so the strict answer would refuse this reset forever and + the queued project change would never land. + """ state, client = _make_state(tmp_path, context_builder=_context_builder()) slot = _make_slot() slot._pending_reset_history_key = "dashboard:chat-1-test" @@ -1706,7 +1715,9 @@ async def test_start_of_turn_resets_before_get_or_create(self, tmp_path): with _patch_stats(): await _run_chat(state, slot, "hello") - state.sessions.reset.assert_any_await("dashboard:chat-1-test", skip_if_busy=True) + state.sessions.reset.assert_any_await( + "dashboard:chat-1-test", skip_if_busy=True, refuse_only_on_active_turn=True + ) # reset() must appear before get_or_create() on the parent sessions mock. sess_calls = state.sessions.mock_calls reset_pos = next(i for i, c in enumerate(sess_calls) if c[0] == "reset") @@ -1743,7 +1754,11 @@ async def test_reset_failure_retains_flag_for_retry(self, tmp_path): @pytest.mark.asyncio async def test_end_of_turn_consumes_flag_set_mid_turn(self, tmp_path): - """Flag set mid-turn (by set_project MCP tool) is consumed in finally.""" + """Flag set mid-turn (by set_project MCP tool) is consumed in finally. + + `refuse_only_on_active_turn` rides the pinned shape for the same reason as the + start-of-turn case: the lease alone must not refuse a queued project change. + """ state, client = _make_state(tmp_path, context_builder=_context_builder()) slot = _make_slot() state.sessions.reset = AsyncMock() @@ -1757,7 +1772,9 @@ def set_flag_mid_stream(*args, **kwargs): with _patch_stats(): await _run_chat(state, slot, "hello") - state.sessions.reset.assert_any_await("dashboard:chat-1-test", skip_if_busy=True) + state.sessions.reset.assert_any_await( + "dashboard:chat-1-test", skip_if_busy=True, refuse_only_on_active_turn=True + ) assert slot._pending_reset_history_key is None diff --git a/test/test_dashboard_chat.py b/test/test_dashboard_chat.py index 07da6841936..9bc98ed0b55 100644 --- a/test/test_dashboard_chat.py +++ b/test/test_dashboard_chat.py @@ -7936,6 +7936,104 @@ async def _pop_then_raise(*_a, **_k): assert slot.agent == "research" assert slot.workspace == "research-ws" + @pytest.mark.asyncio + async def test_api_chat_slot_agent_refuses_a_switch_on_a_busy_channel_session( + self, tmp_path, monkeypatch + ): + """An agent switch must not tear down an in-flight CHANNEL reply. + + A channel-born slot runs its turns on the channel's own session, so the + switch's reset addresses a session a Slack reply may be streaming on right + now -- and channel dispatch does not take ``slot._lock``, so the lock the + handler holds does not serialize against it. Forcing the teardown drops + that reply mid-stream with nothing to recover it. + + So the reset is allowed to DECLINE, and the switch then FAILS CLOSED: a live + turn answers 409 ``turn_in_flight`` and the committed fields are rolled back, + which is the workspace handler's template. Nothing is left owed, because + nothing was kept: a caller is never told a switch succeeded while the + session it names still serves the old binding. + + The provider double is ``spec=LLMProvider`` deliberately: the handler gates its + decline ladder on ``isinstance``, so a plain ``MagicMock`` would read as "no + live provider" and this test would pass while exercising nothing. It reads IDLE + at the pre-commit gate and declines only at the reset, which is the race the + guard exists for -- a provider already busy at the gate is refused there and + never reaches the reset, so it could not detect ``skip_if_busy`` at all. + """ + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + state = _make_state(tmp_path) + slot = state.get_or_create_slot("s1") + slot.agent = "oncall" + slot.workspace = "oncall-ws" + slot.project = "/tmp/oncall" + # Channel-born: its turns run on the channel's session, not `dashboard:s1`. + slot.linked_session_key = "slack:1712345678.9012" + + reset_calls: list = [] + + async def _decline_because_busy(key, **kw): + reset_calls.append((key, kw)) + # What SessionManager.reset does when a turn is live and the caller + # allowed it to decline; forcing it is the teardown under test. + return not kw.get("skip_if_busy", False) + + state.sessions.reset = AsyncMock(side_effect=_decline_because_busy) + from kiro_crew.providers.base import LLMProvider + + busy = MagicMock(spec=LLMProvider) + busy.has_active_turn = MagicMock(return_value=False) + state.sessions.get_provider = MagicMock(return_value=busy) + + mock_cfg = MagicMock() + mock_cfg.agents = {"research": MagicMock(workspace="research-ws", memory_store="default")} + mock_cfg.workspaces = {"research-ws": MagicMock(dir="/tmp/research")} + mock_cfg.default_workspace = "default" + mock_cfg.default_memory_store = "default" + mock_cfg.memory_stores = {} + mock_cfg.memory = MagicMock() + mock_bindings = MagicMock() + mock_bindings.workspace_dir = Path("/tmp/research") + mock_bindings.memory_store_name = "default" + mock_bindings.model = "" + monkeypatch.setattr("kiro_crew.dashboard.chat.KiroCrewConfig.load", lambda: mock_cfg) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_handlers.KiroCrewConfig.load", lambda: mock_cfg + ) + for mod in ("chat", "chat_handlers"): + monkeypatch.setattr( + f"kiro_crew.dashboard.{mod}.resolve_agent_bindings", + lambda cfg, name, project_dir=None: mock_bindings, + ) + monkeypatch.setattr( + f"kiro_crew.dashboard.{mod}._workspace_name_for_dir", + lambda cfg, ws_dir: "research-ws", + ) + + async with TestClient(TestServer(_make_app_with_agent_routes(state))) as client: + resp = await client.post("/api/chat/slots/s1/agent", json={"agent": "research"}) + data = await resp.json() + + assert resp.status == 409 and data.get("code") == "turn_in_flight", ( + "a live channel turn must fail the switch closed, not report success for a " + f"session that still serves the old binding; got {resp.status} {data!r}" + ) + assert slot.agent == "oncall", ( + "and the committed fields must be rolled back -- a 409 that leaves the new " + f"agent in place diverges the store from the session; got {slot.agent!r}" + ) + + assert reset_calls, "precondition: the switch has to attempt a reset at all" + key, kw = reset_calls[-1] + assert key == "slack:1712345678.9012", ( + "precondition: the reset must target the channel's live session -- if it " + f"named a nonexistent key this test could not detect the teardown; got {key!r}" + ) + assert kw.get("skip_if_busy") is True, ( + "an agent switch on a channel-linked slot must let a live turn DECLINE the " + "reset: forcing it tears down a streaming channel reply with no recovery" + ) + @pytest.mark.asyncio async def test_api_chat_slot_agent_failed_reset_spares_concurrent_writes( self, tmp_path, monkeypatch @@ -11268,7 +11366,7 @@ async def test_signed_out_cli_holds_queue(self, tmp_path, monkeypatch): slot.queue_append("queued during plan") async def _auth_run_chat(s, sl, msg, **kw): - sl._last_turn_auth_required = True # signed-out CLI discovered this stage + sl._queue_held = True # signed-out CLI discovered this stage monkeypatch.setattr("kiro_crew.dashboard.chat_orchestrator._run_chat", _auth_run_chat) start_next = AsyncMock(return_value=False) @@ -11281,6 +11379,37 @@ async def _auth_run_chat(s, sl, msg, **kw): start_next.assert_not_awaited() # queue held for post-login resume assert [i["content"] for i in slot._queue] == ["queued during plan"] + @pytest.mark.asyncio + async def test_stage_handoff_still_drains_when_nothing_is_held(self, tmp_path, monkeypatch): + """Positive control for the gate above: with no hold the end-of-plan handoff + must still drain the queue, so the fix narrows one branch rather than + stranding every queued follow-up after a plan.""" + monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) + monkeypatch.setattr("kiro_crew.dashboard.chat.config_dir", lambda: tmp_path) + from kiro_crew.dashboard.chat import _stage_loop + + state = MagicMock() + state.broadcast_ws = MagicMock() + state.push_slots_update = MagicMock() + state.subagents = MagicMock() + state.subagents.running_agents_for = MagicMock(return_value=[]) + slot = self._make_slot(max_stages=1) + state._slots = {slot.key: slot} + slot.queue_append("queued during plan") + + async def _clean_run_chat(s, sl, msg, **kw): + sl._queue_held = False + + monkeypatch.setattr("kiro_crew.dashboard.chat_orchestrator._run_chat", _clean_run_chat) + start_next = AsyncMock(return_value=True) + monkeypatch.setattr( + "kiro_crew.dashboard.chat_orchestrator._start_next_queued_turn", start_next + ) + + await _stage_loop(state, slot, auto_run=True) + + start_next.assert_awaited_once() + @pytest.mark.asyncio async def test_orchestrating_slot_queues_message(self, tmp_path, monkeypatch): """A mid-plan message QUEUES (not runs) even when slot.task is idle between @@ -15683,6 +15812,12 @@ def _make_state(self, tmp_path, monkeypatch): sessions = MagicMock(count=0) sessions.stop_turn = AsyncMock(return_value="soft") sessions.reset = AsyncMock() + # Async: it resolves a cleared project off-thread, so a plain MagicMock hands the + # handler a non-awaitable and the request answers 500. + sessions.note_project_change = AsyncMock() + sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/workspace/_default" + ) sessions.get_pid = MagicMock(return_value=None) state = DashboardState( sessions=sessions, diff --git a/test/test_dashboard_chat_handlers_coverage.py b/test/test_dashboard_chat_handlers_coverage.py index 16a183f0a23..7990933e0fa 100644 --- a/test/test_dashboard_chat_handlers_coverage.py +++ b/test/test_dashboard_chat_handlers_coverage.py @@ -55,6 +55,9 @@ def _state(slot: _ChatSlot | None = None) -> DashboardState: state.broadcast_ws = MagicMock() state.broadcast_context_usage = MagicMock() state.sessions = MagicMock() + # Both are awaited by the project-change producers; a bare MagicMock is not awaitable. + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=lambda key, cwd: cwd or "/w/_default") + state.sessions.note_project_change = AsyncMock() state.sessions.get_provider = MagicMock(return_value=None) return state diff --git a/test/test_eager_spawn.py b/test/test_eager_spawn.py index de6a8811d07..8085e1934ac 100644 --- a/test/test_eager_spawn.py +++ b/test/test_eager_spawn.py @@ -52,6 +52,8 @@ def _mock_state(slot: _ChatSlot) -> DashboardState: state.sessions.remove = AsyncMock() state.sessions.remove_if_unclaimed = AsyncMock(return_value=True) state.sessions.resumable_hint = MagicMock(return_value=True) + # Resolve-only helper: async, and must return a real path string for the arm sites. + state.sessions.resolve_arm_cwd = AsyncMock(side_effect=lambda key, cwd: cwd or "/w/_default") return state @@ -395,13 +397,20 @@ async def test_bails_when_turn_running_without_consuming_reset(self, tmp_path): @pytest.mark.asyncio async def test_consumes_pending_reset_when_idle(self, tmp_path): + """An idle slot's queued reset lands, and refuses only on an ACTIVE TURN. + + The eager spawn runs precisely when the slot is idle, so a strict busy answer keyed on + the lifetime lease would refuse every reset this path exists to consume. + """ slot = _ChatSlot("t1") slot.project = str(tmp_path) slot._pending_reset_history_key = "dashboard:t1" state = _mock_state(slot) with patch.object(chat_runner.KiroCrewConfig, "load", _cfg(True)): await _eager_spawn(state, slot) - state.sessions.reset.assert_awaited_once_with("dashboard:t1", skip_if_busy=True) + state.sessions.reset.assert_awaited_once_with( + "dashboard:t1", skip_if_busy=True, refuse_only_on_active_turn=True + ) assert slot._pending_reset_history_key is None state.sessions.get_or_create.assert_awaited_once() @@ -726,7 +735,14 @@ async def test_agent_switch_schedules_eager_spawn(self): # instance attr spec= does not synthesize. None = no live session, so # the re-probe passes and the committed switch proceeds. state.sessions = MagicMock() + state.sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/w/_default" + ) state.sessions.get_provider = MagicMock(return_value=None) + # Resolve-only helper: async, and must return a real path string for the arm sites. + state.sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/w/_default" + ) app = web.Application() app["state"] = state app.router.add_post("/api/chat/slots/{slot}/agent", api_chat_slot_agent) @@ -743,6 +759,60 @@ async def test_agent_switch_schedules_eager_spawn(self): assert resp.status == 200 sched.assert_called_once_with(state, slot) + @pytest.mark.asyncio + async def test_channel_linked_switch_advances_the_linked_session(self): + """A channel-linked slot's turns run on the CHANNEL's session, not `dashboard:`. + + `_history_key_for` prefixes unconditionally, so a slot linked to `slack:` + yields `dashboard:t1` -- a key nothing claims. The generation would advance there + while the linked session kept the arm from the previous project, so the next turn's + relative writes land in the directory the switch just moved away from. + """ + from aiohttp import web + from aiohttp.test_utils import TestClient, TestServer + + from kiro_crew.dashboard.chat import api_chat_slot_agent + + slot = _ChatSlot("t1") + slot.linked_session_key = "slack:1700000000.000100" + state = MagicMock(spec=DashboardState) + state._slots = {slot.key: slot} + state.push_slots_update = MagicMock() + state.conversation_log = None # instance attr; spec= does not provide it + state.sessions = MagicMock() # same: set in __init__, so spec= omits it + state.sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/w/_default" + ) + app = web.Application() + app["state"] = state + app.router.add_post("/api/chat/slots/{slot}/agent", api_chat_slot_agent) + with ( + patch( + "kiro_crew.dashboard.chat_handlers._reset_slot_session", + new=AsyncMock(), + ) as reset, + patch("kiro_crew.dashboard.chat_handlers.save_slot_off_loop", new=AsyncMock()), + patch("kiro_crew.dashboard.chat_handlers.schedule_eager_spawn"), + ): + async with TestClient(TestServer(app)) as client: + resp = await client.post("/api/chat/slots/t1/agent", json={"agent": "kirocrew"}) + assert resp.status == 200 + + # `mark_retire_on_next_claim`, not `note_project_change`: an agent switch is an + # IDENTITY change, so the arm alone cannot express it (see session.md). + state.sessions.mark_retire_on_next_claim.assert_called_once_with( + # No directory: this slot was never scoped, and stating the resolved default + # would bind it where a clear would, costing the resume the claim still expects. + "slack:1700000000.000100", + None, + agent="kirocrew", + ) + state.sessions.note_project_change.assert_not_called() + assert reset.await_args.args[2] == "slack:1700000000.000100", ( + "the teardown must target the same session the generation advanced, or the " + f"switch resets a key nothing runs on; got {reset.await_args.args[2]!r}" + ) + @pytest.mark.asyncio async def test_project_change_schedules_eager_spawn(self, tmp_path): from aiohttp import web @@ -754,6 +824,12 @@ async def test_project_change_schedules_eager_spawn(self, tmp_path): state = MagicMock(spec=DashboardState) state._slots = {slot.key: slot} state.push_slots_update = MagicMock() + # The handler now arms synchronously on a project change, so the double must + # carry `sessions` (as _mock_state does) or the spec'd mock answers 500. + state.sessions = MagicMock() + state.sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/w/_default" + ) app = web.Application() app["state"] = state app.router.add_post("/api/chat/slots/{slot}/project", api_chat_slot_project) @@ -1205,6 +1281,9 @@ async def test_fresh_spawns_beyond_cap_evict_the_oldest_unclaimed(self): shared_sessions.remove = AsyncMock() shared_sessions.remove_if_unclaimed = AsyncMock(return_value=True) bindings = _bindings() + shared_sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/w/_default" + ) keys: list[str] = [] with ( patch.object(chat_runner.KiroCrewConfig, "load", _cfg(True)), @@ -1740,6 +1819,9 @@ def _state(self, slot, *, has_session=False, resumable="prior-sid"): state = MagicMock(spec=DashboardState) state.get_slot = MagicMock(return_value=slot) state.sessions = MagicMock() + state.sessions.resolve_arm_cwd = AsyncMock( + side_effect=lambda key, cwd: cwd or "/w/_default" + ) state.sessions.has_session = MagicMock(return_value=has_session) state.sessions.resumable_hint = MagicMock(return_value=bool(resumable)) return state diff --git a/test/test_handlers_channel_clear_context.py b/test/test_handlers_channel_clear_context.py index 5cf7fa76be5..488a2977044 100644 --- a/test/test_handlers_channel_clear_context.py +++ b/test/test_handlers_channel_clear_context.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import json from unittest.mock import AsyncMock, MagicMock, patch @@ -43,6 +44,263 @@ def _make_request(ch_id: str, body: dict, channel=None, sessions=None): return request +class TestAClearInTheInterTurnWindowIsRefused: + """Acknowledged-but-undequeued work must refuse the clear, not be wiped out from under it. + + A post is acknowledged to its sender and persisted the moment it reaches the inbox, but + the turn is declared only when the member dequeues it. In between, the session reports no + active turn -- so a clear that consults the turn alone succeeds, wipes the log, and the + member then runs the erased prompt and answers into whatever replaced it. + """ + + @pytest.mark.asyncio + async def test_a_queued_message_refuses_its_member_and_spares_the_log(self): + agent = _make_agent("a1", "Researcher", "channel:ch1:a1") + agent.inbox = asyncio.Queue() + agent.inbox.put_nowait(MagicMock()) + + ch = _make_channel("ch1", {"a1": agent}) + log_before = list(ch.messages) + assert log_before, "precondition: the channel log was already empty" + + sessions = AsyncMock() + # The session would answer the clear: it declares no turn in this window. + sessions.discard_conversation = AsyncMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + body = json.loads(resp.body.decode()) + assert body.get("busy") == ["Researcher"], ( + "a member holding an acknowledged message was not refused, so the clear is " + f"acknowledged for work that still runs; body={body}" + ) + assert not body.get("cleared"), f"the clear was acknowledged; body={body}" + assert sessions.discard_conversation.await_count == 0, ( + "the session was discarded for a member with queued work, which is the " + "acknowledgement the sender sees before its prompt is erased" + ) + assert list(ch.messages) == log_before, ( + "the log was wiped while a member still held an acknowledged prompt, so its reply " + "lands in a conversation that no longer contains the question" + ) + + @pytest.mark.asyncio + async def test_a_hung_teardown_releases_the_log_lock_and_refuses_the_member(self): + """`post` shares `_log_lock`, so an unbounded shutdown wedges the whole channel. + + The teardown is awaited while the lock is held and has no timeout of its own at this + layer, so one provider that never returns from `shutdown()` stops every message in the + channel rather than just this clear. The member is reported REFUSED, not cleared: the + teardown is still running, so answering cleared would speak for unfinished work. + """ + import kiro_crew.dashboard.handlers_channel as hc + + agent = _make_agent("a1", "Researcher", "channel:ch1:a1") + ch = _make_channel("ch1", {"a1": agent}) + # A real lock: the fixture's Mock answers `.locked()` truthy, so the release + # assertion below would pass against any behaviour at all. + ch._log_lock = asyncio.Lock() + log_before = list(ch.messages) + + hung = asyncio.Event() + sessions = AsyncMock() + + async def _never_returns(*_a, **_k): + await hung.wait() + return True + + sessions.discard_conversation = AsyncMock(side_effect=_never_returns) + # Explicit: still registered, so nothing destructive has committed and a refusal is + # the truthful answer. A bare AsyncMock would answer truthy here by accident. + sessions.has_session = MagicMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch.object(hc, "_CLEAR_DISCARD_TIMEOUT_SECS", 0.05): + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await asyncio.wait_for(api_channel_clear_context(request), timeout=5.0) + + body = json.loads(resp.body.decode()) + assert body.get("busy") == ["Researcher"], ( + f"a member whose teardown never returned was not refused; body={body}" + ) + assert not body.get("cleared"), f"unfinished teardown reported as cleared; body={body}" + assert list(ch.messages) == log_before, "the log was wiped on a refused clear" + assert not ch._log_lock.locked(), "the lock outlived the request, wedging every post" + hung.set() + + @pytest.mark.asyncio + async def test_a_refused_member_is_not_discarded_after_the_answer(self): + """The refusal and the teardown must not BOTH win. + + The teardown is shielded so the deadline releases `_log_lock` without cancelling it, + and `discard_conversation` pops the session and clears the SID EARLY, before the slow + `provider.shutdown()`. So a member whose `wait_for` expires before its own pop is + reported busy -- "Nothing was cleared" -- and the shielded task then pops and clears + the SID anyway, discarding the session the API just said it had kept. The SID is + dropped rather than retained, so the next turn cold-starts with no history. + + Reached by the shared deadline rather than by an extreme: an earlier member's slow + shutdown exhausts it, so a later member gets `timeout ~= 0`. + """ + import kiro_crew.dashboard.handlers_channel as hc + + agent = _make_agent("a1", "Researcher", "channel:ch1:a1") + ch = _make_channel("ch1", {"a1": agent}) + ch._log_lock = asyncio.Lock() + + state = {"destroyed": False} + sessions = AsyncMock() + + async def _pops_late(*_a, **_k): + # The real ordering: the destructive half lands, then the slow shutdown. + await asyncio.sleep(0.05) + state["destroyed"] = True + await asyncio.sleep(0.2) + return True + + sessions.discard_conversation = AsyncMock(side_effect=_pops_late) + sessions.has_session = MagicMock(side_effect=lambda _k: not state["destroyed"]) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch.object(hc, "_CLEAR_DISCARD_TIMEOUT_SECS", 0.01): + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await asyncio.wait_for(api_channel_clear_context(request), timeout=5.0) + body = json.loads(resp.body.decode()) + refused = "Researcher" in (body.get("busy") or []) + + # Let anything the handler left running finish, the way the process would. + await asyncio.sleep(0.4) + + assert not (refused and state["destroyed"]), ( + "the API reported this member as NOT cleared, then its teardown discarded the " + "session and its SID anyway -- the next turn starts with no history and the 409 " + f"that promised otherwise is a lie; body={body}" + ) + + @pytest.mark.asyncio + async def test_the_whole_clear_shares_one_deadline_across_members(self): + """N members must not cost N timeouts: `post` waits on the same `_log_lock`. + + A per-member bound is spent once per member, so a clear-all on a channel whose + providers are all slow holds the lock for N x the bound and stalls every message in + the channel for that long. The deadline is taken once, before the first member. + """ + import kiro_crew.dashboard.handlers_channel as hc + + members = { + f"a{i}": _make_agent(f"a{i}", f"Role{i}", f"channel:ch1:a{i}") for i in range(1, 5) + } + ch = _make_channel("ch1", members) + ch._log_lock = asyncio.Lock() + + hung = asyncio.Event() + sessions = AsyncMock() + + async def _never_returns(*_a, **_k): + await hung.wait() + return True + + sessions.discard_conversation = AsyncMock(side_effect=_never_returns) + sessions.has_session = MagicMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + loop = asyncio.get_running_loop() + started = loop.time() + with patch.object(hc, "_CLEAR_DISCARD_TIMEOUT_SECS", 0.2): + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await asyncio.wait_for(api_channel_clear_context(request), timeout=5.0) + elapsed = loop.time() - started + hung.set() + + assert len(members) == 4, "fewer than two members cannot show the multiplication" + assert resp.status == 409, "every member was busy, so this is a total refusal" + assert elapsed < 0.2 * len(members), ( + f"the clear spent its bound once per member: {elapsed:.2f}s across {len(members)} " + f"members against a 0.2s deadline, so every post waited that long" + ) + + @pytest.mark.asyncio + async def test_a_slow_shutdown_after_the_pop_is_reported_cleared_not_refused(self): + """A timeout is only a refusal while nothing destructive has committed. + + `discard_conversation` pops the session and clears the SID BEFORE awaiting + `provider.shutdown()`, so a shutdown that outruns the bound leaves the conversation + already gone. Reporting that member refused answers "Nothing was cleared" while its + next turn starts empty -- the transcript is the only witness, and it is wiped. + """ + import kiro_crew.dashboard.handlers_channel as hc + + agent = _make_agent("a1", "Researcher", "channel:ch1:a1") + ch = _make_channel("ch1", {"a1": agent}) + ch._log_lock = asyncio.Lock() + + hung = asyncio.Event() + sessions = AsyncMock() + + async def _destroys_then_hangs(*_a, **_k): + await hung.wait() + return True + + sessions.discard_conversation = AsyncMock(side_effect=_destroys_then_hangs) + # The registry AFTER the pop: the destructive half already committed. + sessions.has_session = MagicMock(return_value=False) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch.object(hc, "_CLEAR_DISCARD_TIMEOUT_SECS", 0.05): + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await asyncio.wait_for(api_channel_clear_context(request), timeout=5.0) + + body = json.loads(resp.body.decode()) + assert resp.status == 200, f"a committed clear answered a refusal status; body={body}" + assert body.get("cleared") == ["Researcher"], ( + "the session was already discarded, so answering anything but cleared tells the " + f"user their history survived; body={body}" + ) + assert not body.get("busy"), f"a committed teardown was reported refused; body={body}" + assert not ch._log_lock.locked(), "the lock outlived the request, wedging every post" + hung.set() + + @pytest.mark.asyncio + async def test_an_empty_inbox_still_clears(self): + """The refusal must be the queued message, not the presence of an inbox.""" + agent = _make_agent("a1", "Researcher", "channel:ch1:a1") + agent.inbox = asyncio.Queue() + + ch = _make_channel("ch1", {"a1": agent}) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + body = json.loads(resp.body.decode()) + assert body.get("cleared") == ["Researcher"], ( + f"an idle member was refused, so no channel can ever be cleared; body={body}" + ) + assert ch.messages == [], "the log survived a fully clean clear" + + class TestChannelClearContext: @pytest.mark.asyncio async def test_returns_404_when_channel_not_found(self): @@ -62,7 +320,7 @@ async def test_clears_all_agents(self): } ch = _make_channel("ch1", agents) sessions = AsyncMock() - sessions.reset = AsyncMock() + sessions.discard_conversation = AsyncMock() request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) with patch( @@ -74,7 +332,7 @@ async def test_clears_all_agents(self): body = json.loads(resp.body) assert body["ok"] is True assert set(body["cleared"]) == {"Researcher", "Writer"} - assert sessions.reset.call_count == 2 + assert sessions.discard_conversation.call_count == 2 assert ch.messages == [] assert ch._msg_index == {} assert ch.exchange_counts == {} @@ -88,7 +346,7 @@ async def test_clears_single_agent(self): } ch = _make_channel("ch1", agents) sessions = AsyncMock() - sessions.reset = AsyncMock() + sessions.discard_conversation = AsyncMock() request = _make_request( "ch1", {"scope": "agent", "agent_id": "a1"}, channel=ch, sessions=sessions @@ -102,10 +360,345 @@ async def test_clears_single_agent(self): body = json.loads(resp.body) assert body["ok"] is True assert body["cleared"] == ["Researcher"] - sessions.reset.assert_called_once_with("channel:ch1:a1") + assert "busy" not in body, ( + "the success path carries no busy list -- it is constantly empty there, since a " + f"non-empty one returns ok:false above; got {body}" + ) + # skip_if_busy: a turn can be streaming on the channel agent, so forcing the + # teardown would drop that reply; the refusal is reported in `busy` instead. + # `refuse_only_on_active_turn` is pinned deliberately: without it a member's + # lifetime lease refuses this clear for as long as the member exists. + sessions.discard_conversation.assert_called_once_with( + "channel:ch1:a1", skip_if_busy=True, refuse_only_on_active_turn=True + ) # Messages and exchange_counts NOT cleared for single-agent scope assert len(ch.messages) == 2 + @pytest.mark.asyncio + async def test_a_key_with_no_live_session_counts_as_cleared_not_busy(self): + """A member with nothing registered must not be reported busy. + + `discard_conversation` reports False ONLY for a `skip_if_busy` refusal over a LIVE + session -- an absent key takes the teardown path and answers True -- so the endpoint + gets its "nothing to clear is already cleared" behaviour from that contract, with no + membership probe. Stubbing False here alongside an absent session would fabricate a + return the lifecycle service cannot produce; the contract itself is pinned by + ``TestDiscardConversation.test_an_absent_key_is_not_reported_as_a_busy_refusal``. + """ + agents = {"a1": _make_agent("a1", "Researcher", "channel:ch1:a1")} + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=True) + + request = _make_request( + "ch1", {"scope": "agent", "agent_id": "a1"}, channel=ch, sessions=sessions + ) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + assert resp.status == 200, ( + "a key with no live session is already in the state the caller asked for, so " + f"it must not answer 409; got {resp.status}" + ) + body = json.loads(resp.body) + assert body.get("busy") in (None, []), f"and it must not be reported busy; got {body}" + assert "Researcher" in body.get("cleared", []), ( + f"the absent member must be reported cleared; got {body}" + ) + + @pytest.mark.asyncio + async def test_a_total_refusal_does_not_destroy_the_shared_message_log(self): + """The 409 must be answered BEFORE the buffer wipe, not after it. + + `scope="all"` on a channel where every member is mid-turn clears nothing, and + the wipe below the reset loop is SHARED channel state that `_save()` persists. + Answering 409 after running it destroyed the transcript the response reports as + untouched -- silent, irreversible, and the opposite of what the caller is told. + Asserts the buffers survive AND that nothing was persisted, since either alone + would pass while the other still lost the log. + """ + agents = { + "a1": _make_agent("a1", "Researcher", "channel:ch1:a1"), + "a2": _make_agent("a2", "Analyst", "channel:ch1:a2"), + } + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=False) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + assert resp.status == 409, f"a clear that cleared nothing must not answer 200; got {resp.status}" + assert len(ch.messages) == 2, ( + "the shared message log must SURVIVE a total refusal -- the 409 reports it " + f"untouched, so wiping it makes the response a lie; got {len(ch.messages)}" + ) + assert len(ch._msg_index) == 2, f"the message index must survive too; got {ch._msg_index}" + assert ch.exchange_counts == {("a", "b"): 3}, f"and the counts; got {ch.exchange_counts}" + ch._save.assert_not_called() + + @pytest.mark.asyncio + async def test_the_broadcast_carries_only_what_its_listener_reads(self): + """A field no consumer reads is a claim about the wire that nothing checks. + + The gate above the broadcast forces `scope == "all"` and `busy == []`, so a per-agent + id and a busy list are not merely unread here -- they are empty BY CONSTRUCTION, and a + later reader trusting either would be reading a constant. The sole listener keys on + `channel_id` and `scope`; the payload now states exactly that and nothing more. + """ + agents = {"a1": _make_agent("a1", "Researcher", "channel:ch1:a1")} + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=True) + sessions.has_session = MagicMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + assert resp.status == 200 + sent = [c.args for c in ch._broadcast.call_args_list if c.args and c.args[0] == "channel_context_cleared"] + assert len(sent) == 1, f"a clean clear must announce itself exactly once; got {sent}" + assert set(sent[0][1]) == {"channel_id", "scope"}, ( + "the payload must carry only the fields the listener reads -- anything else is " + f"an unchecked wire claim; got {sorted(sent[0][1])}" + ) + + @pytest.mark.asyncio + async def test_a_partial_clear_does_not_announce_a_wipe_to_other_tabs(self): + """The broadcast is what OTHER tabs act on, so it must follow the wipe, not the request. + + A listener handles `channel_context_cleared` by REPLACING its retained transcript with an + empty list. Announcing it unconditionally meant a partial clear -- which deliberately keeps + the shared log for the busy member -- destroyed that same log in every other tab, out of + band from the response and with nothing to restore it. The event now follows the wipe. + """ + agents = { + "a1": _make_agent("a1", "Researcher", "channel:ch1:a1"), + "a2": _make_agent("a2", "Analyst", "channel:ch1:a2"), + } + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(side_effect=[True, False]) + sessions.has_session = MagicMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + assert resp.status == 200 + body = json.loads(resp.body) + assert body.get("busy"), f"precondition: this must be the partial path; got {body}" + assert len(ch.messages) == 2, "precondition: the shared log survived the partial clear" + events = [c.args[0] for c in ch._broadcast.call_args_list if c.args] + assert "channel_context_cleared" not in events, ( + "a partial clear kept the shared log, so announcing it tells every other tab to " + f"replace that log with an empty list; broadcast {events}" + ) + + @pytest.mark.asyncio + async def test_a_partial_clear_leaves_the_shared_log_the_busy_member_still_references(self): + """A PARTIAL clear-all must not wipe shared state, only a fully clean one may. + + The total refusal answers 409 above the wipe, and a fully clean clear reaches it + legitimately -- but the PARTIAL case answers 200 and fell through to the same + unconditional wipe. The busy member keeps the LLM context that quotes the shared + transcript, so emptying it strands that member: its in-flight reply appends to a + log the rest of its context still refers to, and no path restores what was lost. + """ + agents = { + "a1": _make_agent("a1", "Researcher", "channel:ch1:a1"), + "a2": _make_agent("a2", "Analyst", "channel:ch1:a2"), + } + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + # a1 clears, a2 is mid-turn: the 200 partial path, not the 409 total one. + sessions.discard_conversation = AsyncMock(side_effect=[True, False]) + sessions.has_session = MagicMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + assert resp.status == 200, ( + "a partial clear cleared something, so the contract's 200 stands -- `ok` is what " + f"marks it incomplete; got {resp.status}" + ) + body = json.loads(resp.body) + assert ( + body.get("ok") is False + ), f"a caller reading only `ok` must see a partial clear as incomplete; got {body}" + assert body.get("busy"), f"precondition: the partial path must report a busy member; got {body}" + assert len(ch.messages) == 2, ( + "the shared log must SURVIVE a partial clear -- the member reported busy keeps " + f"the context that references it; got {len(ch.messages)}" + ) + assert len(ch._msg_index) == 2, f"the message index must survive too; got {ch._msg_index}" + assert ch.exchange_counts == {("a", "b"): 3}, f"and the counts; got {ch.exchange_counts}" + + @pytest.mark.asyncio + async def test_the_refusal_carries_a_machine_readable_code(self): + """`error` prose is advisory and untranslatable; `code` is the contract. + + The dashboard renders `res.error` verbatim into a localized UI, so a coded + response is what lets a caller branch on the cause. Pinned here as well as in + the repo-wide ratchet so the reason is readable at the site that owes it. + """ + agents = {"a1": _make_agent("a1", "Researcher", "channel:ch1:a1")} + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=False) + + request = _make_request( + "ch1", {"scope": "agent", "agent_id": "a1"}, channel=ch, sessions=sessions + ) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + body = json.loads(resp.body) + assert body.get("code") == "turn_in_flight", ( + "the refusal must carry a machine-readable code, not prose alone; " f"got {body}" + ) + + @pytest.mark.asyncio + async def test_a_total_refusal_answers_409_not_a_false_success(self): + """Reporting the refusal into a field nothing reads IS the silent no-op. + + An earlier version answered 200 with a `busy` list and no reader, so the caller + rendered a clear that never happened -- a success signal that is not proof of + effect. When NOTHING cleared the endpoint now fails, which reaches the user through + the caller's existing error path. Declining the reset is still right: forcing it + would tear down a streaming reply. + """ + agents = {"a1": _make_agent("a1", "Researcher", "channel:ch1:a1")} + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=False) + + request = _make_request( + "ch1", {"scope": "agent", "agent_id": "a1"}, channel=ch, sessions=sessions + ) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + assert resp.status == 409, f"a clear that cleared nothing must not answer 200; got {resp.status}" + body = json.loads(resp.body) + assert "Researcher" in body.get("error", ""), ( + "and the error must name what refused, or the user cannot tell what to retry; " + f"got {body}" + ) + assert body["busy"] == ["Researcher"] + + @pytest.mark.asyncio + async def test_a_member_holding_no_live_session_is_not_reported_cleared(self): + """`discard_conversation` answers True for a key holding nothing. + + At the reporting site that is indistinguishable from a real clear, so an idle member + with no session was credited to `cleared` and the response named work this endpoint + did not do. Presence is read BEFORE the discard, and only a session that existed can + be reported cleared. + """ + agents = {"a1": _make_agent("a1", "Researcher", "channel:ch1:a1")} + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=True) + # The registry answer is the whole point, so it is set explicitly: a bare AsyncMock + # returns a truthy Mock and the assertion below could not fail. + sessions.has_session = MagicMock(return_value=False) + + request = _make_request( + "ch1", {"scope": "agent", "agent_id": "a1"}, channel=ch, sessions=sessions + ) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + body = json.loads(resp.body) + assert body.get("cleared") == [], ( + "a member holding no live session was reported cleared, so the response credits a " + f"clear that never happened; got {body}" + ) + + @pytest.mark.asyncio + async def test_a_member_holding_a_live_session_is_still_reported_cleared(self): + """The positive control: gating on presence must not silence a REAL clear.""" + agents = {"a1": _make_agent("a1", "Researcher", "channel:ch1:a1")} + ch = _make_channel("ch1", agents) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=True) + sessions.has_session = MagicMock(return_value=True) + + request = _make_request( + "ch1", {"scope": "agent", "agent_id": "a1"}, channel=ch, sessions=sessions + ) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + body = json.loads(resp.body) + assert body.get("cleared") == ["Researcher"], f"a real clear went unreported; got {body}" + + @pytest.mark.asyncio + async def test_a_member_working_on_a_dequeued_message_refuses_before_the_wipe(self): + """The window between dequeue and the turn being declared must still refuse. + + `has_queued_work` reads the inbox DEPTH, which drops to zero the moment the message is + taken, while the active turn is declared later in the stream task. A clear arriving in + that window finds neither the queue nor an active turn, tears the session down, and -- + for `scope=all` -- takes the shared transcript with it via `_save()`, which commits the + deletion with nothing to recover from. The member's own state is what covers it. + """ + agent = _make_agent("a1", "Researcher", "channel:ch1:a1") + agent.state = "working" + # The message is already TAKEN, so the queue is empty -- the whole point of the window. + agent.inbox = MagicMock(qsize=MagicMock(return_value=0)) + ch = _make_channel("ch1", {"a1": agent}) + sessions = AsyncMock() + sessions.discard_conversation = AsyncMock(return_value=True) + sessions.has_session = MagicMock(return_value=True) + + request = _make_request("ch1", {"scope": "all"}, channel=ch, sessions=sessions) + with patch( + "kiro_crew.dashboard.handlers_channel._mgr", + return_value=MagicMock(get=MagicMock(return_value=ch)), + ): + resp = await api_channel_clear_context(request) + + body = json.loads(resp.body) + assert resp.status == 409, f"a working member must refuse the clear; got {resp.status} {body}" + assert body.get("busy") == ["Researcher"], f"and must be named as busy; got {body}" + assert len(ch.messages) == 2, ( + "the shared transcript was wiped while a member was working on a dequeued message, " + f"and `_save()` commits that deletion; {len(ch.messages)} message(s) left" + ) + sessions.discard_conversation.assert_not_awaited() + @pytest.mark.asyncio async def test_returns_404_for_unknown_agent_id(self): agents = {"a1": _make_agent("a1", "Researcher", "channel:ch1:a1")} diff --git a/test/test_mcp_core_set_project.py b/test/test_mcp_core_set_project.py index e7ee6f04bcc..5fb015d500d 100644 --- a/test/test_mcp_core_set_project.py +++ b/test/test_mcp_core_set_project.py @@ -15,6 +15,7 @@ from __future__ import annotations +import asyncio import os from unittest.mock import patch @@ -246,11 +247,26 @@ def __init__(self, key: str = "dashboard:test-slot", project: str = ""): self._pending_reset_history_key = None +class _FakeSessions: + """Records the arm calls, and YIELDS in the resolve like the real one does.""" + + def __init__(self): + self.armed: list[tuple[str, str]] = [] + + async def resolve_arm_cwd(self, key: str, cwd: str) -> str: + await asyncio.sleep(0) + return "/workspace/_default" + + def mark_retire_on_next_claim(self, key: str, cwd: str, *, agent=None) -> None: + self.armed.append((key, cwd)) + + class _FakeState: """Minimal state: the applier only calls ``push_slots_update``.""" def __init__(self): self.pushes = 0 + self.sessions = _FakeSessions() def push_slots_update(self) -> None: self.pushes += 1 @@ -278,6 +294,63 @@ async def test_sets_project_realpath_and_flags_history_reset(self, tmp_path): assert state.pushes == 1 assert "Project set to" in result + @pytest.mark.asyncio + async def test_a_failed_arm_resolve_leaves_the_cleared_project_untouched(self, tmp_path): + """An unresolvable workspace root must not half-apply the clear. + + Mutating first and resolving after leaves the slot on no project with no arm + raised when the resolve fails, so the next cwd-less channel claim reuses the + session still bound to the OLD project directory. + """ + slot = _FakeSlot(project=str(tmp_path)) + state = _FakeState() + + async def _boom(key, cwd): + raise RuntimeError("workspace root unavailable") + + state.sessions.resolve_arm_cwd = _boom + result = await apply_session_directive( + state, + slot, + slot.key, + "set_project", + {"project": "", "clear": True}, + producer_is_user_facing=True, + ) + assert slot.project == str(tmp_path), ( + "the clear mutated the slot before the resolve failed, so the slot reads " + "cleared while no arm was ever recorded" + ) + assert not slot._pending_reset_history_key + assert state.sessions.armed == [] + assert "could not be resolved" in result + + @pytest.mark.asyncio + async def test_a_cleared_project_is_armed_before_the_producer_returns(self, tmp_path): + """The arm must exist by the time the turn can release the session. + + Arming at the turn-end consume instead leaves a window: that path resolves the + cleared directory with an ``await``, and the lease is already gone, so a queued + channel claim acquires and is served by the session still bound to the old project. + Here the turn still holds it, so the same resolve yields harmlessly. + """ + slot = _FakeSlot(project=str(tmp_path)) + state = _FakeState() + result = await apply_session_directive( + state, + slot, + slot.key, + "set_project", + {"project": "", "clear": True}, + producer_is_user_facing=True, + ) + assert slot.project == "" + assert slot._pending_reset_history_key is not None + assert "Project cleared" in result + assert state.sessions.armed == [ + (slot._pending_reset_history_key, "/workspace/_default") + ], "the clear returned without arming, so the consume-side resolve races a queued claim" + @pytest.mark.asyncio async def test_sensitive_path_denied_without_mutating_slot(self, tmp_path, monkeypatch): slot = _FakeSlot(project="/existing/project") diff --git a/test/test_session.py b/test/test_session.py index adee505283c..b9b93746269 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -7,10 +7,13 @@ import os import threading import time +from dataclasses import replace from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest +from chat_test_helpers import armed_agent, armed_cwd from conftest import requires_symlinks from kiro_crew.acp.runtime import AcpWorkspaceBindingError @@ -20,6 +23,7 @@ from kiro_crew.session import ( _BG_BLIND_RECYCLE_PROMPTS, BACKGROUND_KEY, + SessionBusyError, SessionClosingError, SessionManager, ) @@ -1091,6 +1095,1835 @@ def factory(session_key=None, agent=None, channel_id=None, **kwargs): await mgr.close_all() +def _default_workspace(session_key: str | None = None) -> str: + """The directory a provider for *session_key* binds when handed no ``work_dir``. + + Keyed, because the provider FACTORY resolves an empty ``cwd`` to a per-session + directory rather than to the shared workspace -- modelling the shared one here is + what let the two fallbacks diverge unnoticed. + """ + from kiro_crew.config.loader import session_default_cwd + from kiro_crew.config.paths import default_workspace_dir + + if session_key: + return str(session_default_cwd(session_key)) + return str(default_workspace_dir()) + + +class TestReuseRefusesAMovedProject: + """Reuse is gated on the bound directory, not on liveness alone. + + ``cwd`` is applied when a provider is CREATED and never re-applied, so a live + session whose project has since changed would otherwise be handed back bound + to the OLD directory and run the turn's relative writes there. + + Validated where the SEMAPHORE IS HELD, not at the reuse decision: the decision + runs before the semaphore is claimed, so a turn can be streaming there and + evicting would tear a live reply down mid-stream -- the same harm the deferred + reset exists to prevent. Holding the semaphore means any such turn has + finished, and it still covers every acquisition because they all pass through + that claim. + """ + + # The gate normalises both sides through ``Path``, which on Windows means backslashes, + # so expecting the POSIX spelling would assert the separator rather than the directory. + ALPHA = str(Path("/projects/alpha")) + BETA = str(Path("/projects/beta")) + GAMMA = str(Path("/projects/gamma")) + + def test_no_cwd_assertion_here_hard_codes_a_posix_separator(self): + """Ratchets the whole class against the bug that broke the Windows shard twice. + + Every directory in these tests reaches the assertion through `resolved_cwd`, which + returns `str(Path(cwd))` -- the platform's own spelling. A raw `"/projects/x"` on + the expected side therefore asserts the SEPARATOR, not the directory: it passes on + every POSIX shard and fails only on Windows, so the local suite cannot see it. + + It has now arrived twice from different directions -- a fixture echoing the request + verbatim, then an assertion literal predating the normalised constants -- which is + why this pins the shape rather than the two instances. Compare against `ALPHA` / + `BETA` / `GAMMA`, or build the expectation with the same `Path` call. + """ + import re + from pathlib import Path as _P + + src = _P(__file__).read_text(encoding="utf-8") + start = src.index(f"class {type(self).__name__}") + nxt = src.find("\nclass ", start + 1) + body = src[start : nxt if nxt != -1 else len(src)] + + # The constants themselves are the sanctioned place a POSIX literal appears. + offenders = [ + ln.strip() + for ln in body.splitlines() + if re.search(r'cwd\s*(==|!=)\s*"/', ln) or re.search(r'"/projects/\w+"\s*(==|!=)', ln) + ] + assert not offenders, ( + "these compare a normalised cwd against a hard-coded POSIX path, so they assert " + "the separator and fail only on the Windows shard: " + "; ".join(offenders) + ) + + @staticmethod + def _factory(seen: list): + """A factory whose providers bind ``cwd`` the way a REAL provider does. + + A real provider handed a falsy ``work_dir`` binds to the PER-SESSION workspace for + its key and reports that concrete path as ``cwd``. A mock echoing ``""`` back + cannot exercise the mismatch between an empty request and that default, which is + the shape that wedges a slot; a mock binding the SHARED workspace cannot exercise + the divergence between the two no-cwd fallbacks. + """ + + def factory(session_key=None, agent=None, channel_id=None, cwd=None, **kwargs): + m = AsyncMock() + m.start = AsyncMock() + m.shutdown = AsyncMock() + # A real provider reports a Path, so its cwd comes back in the platform's own + # spelling -- echoing the request verbatim passes on POSIX and fails on Windows. + m.cwd = str(Path(cwd)) if cwd else _default_workspace(session_key) + # Recorded so a test can assert WHICH agent was served, not merely that some + # session registered -- the two differ exactly when an arm re-points a retry. + m.requested_agent = agent + m.context_usage_pct = MagicMock(return_value=0.0) + m.is_alive = MagicMock(return_value=True) + m.is_process_alive = MagicMock(return_value=True) + m.has_active_turn = MagicMock(return_value=False) + m.runtime_info = MagicMock(return_value=(None, None)) + seen.append(m) + return m + + return factory + + @pytest.mark.asyncio + async def test_arming_an_unregistered_key_still_refuses_the_later_session(self, cfg): + """The arm must outlive the absence that made it necessary. + + This is the cold-start window: the teardown is refused while nothing is + registered, so there is no object to pin. Arming the KEY carries the protection + forward, so a cold start that began BEFORE the project changed -- and is + therefore bound to the OLD directory -- is refused on its next claim rather + than reused. A successor already bound to the requested directory is what the + change asked for and is left alone; the arm has nothing to invalidate there. + + Also pins termination: the arm is cleared by the successor bound to the + requested directory, not by the refusal, so the key does not churn forever. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded_cold = mgr._fold_key("sess-cold") + + # Nothing registered yet -- the arm must land on the key regardless. + mgr.mark_retire_on_next_claim("sess-cold", "/projects/beta") + assert armed_cwd(boundary, "sess-cold") is not None + + # The in-flight cold start lands. It was composed BEFORE the change, so it names + # the superseded directory -- and the arm's recorded target is what reveals that. + provider, _, _ = await mgr.get_or_create("sess-cold", cwd="/projects/alpha") + mgr.release("sess-cold") + + assert provider.cwd == self.BETA, ( + "a key armed while nothing was registered must not serve a generation bound " + "to the superseded directory -- the turn's relative writes would land in the " + f"previous project; got {provider.cwd!r}" + ) + + # The next claim names the post-change directory and is correctly bound already. + _, is_new, _ = await mgr.get_or_create("sess-cold", cwd="/projects/beta") + assert is_new is False, ( + "once the pre-change generation has been discarded at registration, the " + "session in the registry is bound to the requested directory and must be " + "reused rather than cold-started again" + ) + + mgr.release("sess-cold") + _, is_new_again, _ = await mgr.get_or_create("sess-cold", cwd="/projects/beta") + assert is_new_again is False, "a satisfied arm must not keep refusing the key" + assert armed_cwd(boundary, folded_cold) is None, ( + "the successor bound to the requested directory is what satisfies the arm, " + "so it must be clear once that session claims -- otherwise the key churns forever" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_the_arm_survives_discarding_one_pre_change_generation(self, cfg): + """One discarded generation must not spend the guard for the next one. + + More than one cold start can be in flight when a project changes, so discarding + the first must leave the arm raised. If the eviction that discards a stale + generation also cleared the arm, the second pre-change provider would register + unguarded and its relative writes would land in the previous project. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-race") + + mgr.mark_retire_on_next_claim("sess-race", "/projects/beta") + await mgr.get_or_create("sess-race", cwd="/projects/alpha") + mgr.release("sess-race") + + assert armed_cwd(boundary, folded) is not None, ( + "discarding one pre-change generation must not spend the arm -- a second " + "cold start begun before the change is still on its way" + ) + + # A second generation begun before the change now reaches registration. + _, is_new, _ = await mgr.get_or_create("sess-race", cwd="/projects/alpha") + assert is_new is True, "the retained arm must refuse the second pre-change generation" + assert ( + boundary._sessions["sess-race"].provider.cwd == self.BETA + ), "no generation bound to the superseded directory may be left serving the key" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_refused_claimant_leaves_the_arm_for_the_next_stale_start(self, cfg): + """A refusal must not spend the arm, or the second stale start goes unguarded. + + Clearing the project names no directory, so the moved-directory teardown + cannot fire and the arm is the ONLY guard. If the first stale cold start + consumes it merely by being registered, the second registers unguarded and + its relative writes still land in the pre-change project. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + + await mgr.get_or_create("sess-two", cwd="/projects/alpha") + mgr.release("sess-two") + first = mgr._sessions["sess-two"] + + mgr.mark_retire_on_next_claim("sess-two", "") + + # Claim with the project CLEARED. The first stale session is refused, but + # nothing about that refusal validates a successor against the new binding. + _, is_new, _ = await mgr.get_or_create("sess-two", cwd=None) + assert is_new is True, "precondition: the armed key refuses the pre-change session" + assert first.retire_on_identity_change is True, "precondition: the refusal marked it" + mgr.release("sess-two") + + alloc = mgr._allocation_boundary() + # The arm is stored under the FOLDED key, so read it through the same accessor + # the production path uses rather than the raw name. + folded = mgr._fold_key("sess-two") + assert armed_cwd(alloc, folded) is not None, ( + "a claim the arm REFUSED must leave the arm set: a second cold start begun " + "before the change is still bound to the old directory, and with the arm " + "spent it registers unguarded and writes into the previous project" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_cleared_project_refuses_a_session_bound_to_a_directory(self, cfg): + """Clearing the project is a REQUIREMENT, not the absence of one. + + A cleared slot states "no directory". Treating that as "no opinion" waives the + comparison entirely, so every claim passes and the reused session keeps writing + its relative paths into the project that was cleared away. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + + await mgr.get_or_create("sess-clear", cwd="/projects/alpha") + mgr.release("sess-clear") + + _, is_new, _ = await mgr.get_or_create("sess-clear", cwd="") + mgr.release("sess-clear") + + assert is_new is True, ( + "a cleared project must refuse a session bound to a directory: an empty " + "requirement is still a requirement, and waiving it leaves the turn " + "writing into the project that was cleared away" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_the_first_cold_start_after_a_clear_is_not_served_the_old_binding(self, cfg): + """A cold start begun before a clear must not serve the turn that follows it. + + Its own cwd was read before the change, so it cannot detect the change by + comparing against itself. Only the directory recorded WITH the arm separates a + pre-change generation from the successor. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + + # The project is cleared while a cold start carrying the OLD directory is still + # on its way, so the arm records "" while the claim still names alpha. + mgr.mark_retire_on_next_claim("sess-race", "") + provider, _, _ = await mgr.get_or_create("sess-race", cwd="/projects/alpha") + mgr.release("sess-race") + + assert provider.cwd == _default_workspace(mgr._fold_key("sess-race")), ( + "the first claim after a clear must be served a provider bound to the " + f"CLEARED project, not the superseded one; got {provider.cwd!r}" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_cleared_project_claim_converges_instead_of_wedging(self, cfg): + """A session naming no directory binds the default workspace, so it must match. + + A provider handed a falsy directory does not report one back: it binds + ``config_dir()/workspace`` and reports that concrete path. So an arm or a request + recorded as the bare empty string can never equal what the session it is waiting + for actually binds. Every generation is then rejected as stale, the retry budget + runs out, and the slot is left permanently unclaimable -- and the same mismatch + cold-starts every project-less session on every turn. Both sides resolving the + empty case to that same workspace path is what makes the comparison terminate. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + + # Exactly the arm a refused teardown raises when the project is CLEARED. + mgr.mark_retire_on_next_claim("sess-wedge", "") + provider, _, _ = await mgr.get_or_create("sess-wedge", cwd="") + mgr.release("sess-wedge") + + assert provider.cwd == _default_workspace(mgr._fold_key("sess-wedge")), ( + "a claim naming no directory must be served the PER-SESSION workspace its own " + f"provider binds, not the shared one; got {provider.cwd!r}" + ) + + # The turn after it must REUSE. Evicting here would pay a cold start every turn + # for every session that has no project selected. + _, is_new, _ = await mgr.get_or_create("sess-wedge", cwd="") + assert is_new is False, ( + "a session already bound to the default workspace satisfies a claim that " + "names no directory -- refusing it evicts every project-less session on " + "every turn" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_directory_match_does_not_spend_the_identity_arm(self, cfg): + """The two arms answer different questions, so one cannot satisfy the other. + + A project-scope agent switch keeps `slot.project`, so the identity arm names the + directory the live session is already bound to. If a directory match spent the agent + arm, the mechanism would be defeated on its own intended consumer path: the claim + reuses a session still running the switched-away agent, the arm is gone, and nothing + self-corrects. + + The arms are placed DIRECTLY rather than through `mark_retire_on_next_claim`, which + also flags the registered session -- that flag refuses the reuse on its own, so a + test going through it passes whether or not the pop rule is right. What is under + test here is the satisfaction rule, so the state it must handle is set up directly. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-arms") + + await mgr.get_or_create("sess-arms", cwd=self.BETA, agent="oncall") + mgr.release("sess-arms") + first = seen[-1] + + # Same directory, different agent -- and no identity flag on the live session. + boundary._arm(folded).cwd = self.BETA + boundary._arm(folded).agent = "research" + assert ( + boundary._sessions[folded].retire_on_identity_change is False + ), "precondition: no flag, so only the arm can refuse this reuse" + + provider, is_new, _ = await mgr.get_or_create("sess-arms", cwd=self.BETA, agent="research") + + assert is_new is True, ( + "the live session runs the switched-away agent, so a matching directory must " + "not let it be reused -- the identity arm is a separate, unsatisfied question" + ) + assert provider is not first, "the replacement must be a different provider" + assert ( + provider.requested_agent == "research" + ), f"and it must run the armed agent; got {provider.requested_agent!r}" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_post_switch_cold_start_cannot_register_the_old_agent(self, cfg): + """The generation and the directory both read clean for this one, so neither guards it. + + A channel turn resolves its agent, the dashboard switch lands, and only THEN does the + turn reach `get_or_create`. Its generation snapshot is taken after the bump, so the + ordering test passes; a project-scope switch leaves the directory alone, so the arm's + directory test passes too. And unlike a live session there is no registered object to + have carried `retire_on_identity_change`. The registering session would therefore run + the agent the switch just replaced, permanently -- the arm is spent by the next + satisfied claim and nothing self-corrects. + + So the arm has to state the DESIRED AGENT and the registration has to be checked + against it. That is the only one of the three disjuncts that can see this case. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + # The switch: same directory, new agent. Nothing is registered yet -- this is the + # cold-start case, so there is no session for the identity flag to land on. + mgr.mark_retire_on_next_claim("sess-race", "/projects/beta", agent="research") + folded = mgr._fold_key("sess-race") + assert ( + armed_agent(boundary, folded) == "research" + ), "precondition: the arm records the agent a successor must be running" + + # The turn had already resolved the OLD agent before the switch landed. + provider, is_new, _ = await mgr.get_or_create("sess-race", cwd=self.BETA, agent="oncall") + + assert is_new is True, "precondition: this is a cold start, not a reuse" + assert provider.requested_agent == "research", ( + "the turn must be served the agent the switch selected, not the one it had " + "already resolved: the generation is current and the directory matches the " + f"arm, so nothing but the agent distinguishes them; got " + f"{provider.requested_agent!r}" + ) + assert [p.requested_agent for p in seen] == ["oncall", "research"], ( + "and the old-agent provider must have been built and then REFUSED rather " + f"than never attempted, or the test proves nothing; got " + f"{[p.requested_agent for p in seen]}" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_an_equivalent_same_key_re_arm_does_not_bump_the_generation(self, cfg): + """The deferred-reset retry re-arms the same key every few seconds; that must be free. + + While sub-agents stay attached the retry re-enters and transfers the arm onto the + SAME key with the SAME target. Each generation bump invalidates a start that has + not finished resolving its model, so a slow cold start is rejected on every pass and + the path eventually gives up. A DIFFERENT target on the same key is a real re-arm and + must still supersede -- the guard has to tell those two apart. + """ + from kiro_crew.config.paths import resolved_cwd + + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = boundary._fold_key("sess-retryloop") + + mgr.mark_retire_on_next_claim("sess-retryloop", self.ALPHA, agent="kirocrew") + armed_gen = boundary._generation(folded) + + for _ in range(3): + mgr.transfer_retire_arm("sess-retryloop", "sess-retryloop", self.ALPHA) + + assert boundary._generation(folded) == armed_gen, ( + "an equivalent same-key re-arm must KEEP the generation -- each bump rejects an " + f"in-flight cold start, so a retry loop starves it; {armed_gen} -> " + f"{boundary._generation(folded)}" + ) + assert armed_cwd(boundary, folded) == resolved_cwd( + self.ALPHA, folded + ), "and the arm itself must survive: preserving the generation must not drop it" + + # A different target on the same key is a genuine re-arm, so it MUST supersede. + mgr.transfer_retire_arm("sess-retryloop", "sess-retryloop", self.BETA) + assert boundary._generation(folded) > armed_gen, ( + "a same-key transfer naming a DIFFERENT directory is a real change and must bump; " + "a too-loose equivalence test would swallow it" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_an_equivalent_same_key_re_arm_keeps_flagging_a_registered_session(self, cfg): + """Preserving the generation must not skip the registered session's retire flag. + + A session can REGISTER between the original arm and a retry pass, and the flag is what + makes a live registration honour the arm. Returning early without setting it would + leave that successor unguarded -- the arm's whole purpose. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + provider, _, _ = await mgr.get_or_create("sess-latereg", cwd=self.ALPHA) + mgr.release("sess-latereg") + folded = boundary._fold_key("sess-latereg") + mgr.mark_retire_on_next_claim("sess-latereg", self.ALPHA, agent="kirocrew") + boundary._sessions[folded].retire_on_identity_change = False + + mgr.transfer_retire_arm("sess-latereg", "sess-latereg", self.ALPHA) + + assert boundary._sessions[folded].retire_on_identity_change is True, ( + "the equivalent re-arm still has to flag the REGISTERED session, or a session that " + "appeared after the first arm never honours it" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_arming_a_cleared_project_resolves_off_thread_not_on_the_loop( + self, cfg, monkeypatch + ): + """`resolve_arm_cwd` exists so the arm sites never resolve a cleared project inline. + + The arm records a resolved directory, and resolving the cleared one mkdirs, stats + and realpaths the workspace root -- which blocks in the kernel on a symlinked or + network root. Every arm and transfer site is reached from an async handler, so doing + it inline stalls the gateway loop. The recorder itself stays synchronous, so the arm + and the transfer keep their atomic commit window. + """ + import threading + + from kiro_crew import session as sess_mod + from kiro_crew.config.paths import CWD_CLEARED + + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + loop_thread = threading.current_thread() + observed: list[bool] = [] + real_resolved = sess_mod.resolved_cwd + + def spy(cwd, session_key=None): + if cwd == "" and session_key: + observed.append(threading.current_thread() is loop_thread) + return real_resolved(cwd, session_key) + + monkeypatch.setattr(sess_mod, "resolved_cwd", spy) + + armed = await mgr.resolve_arm_cwd("sess-armsite", CWD_CLEARED) + + assert observed, ( + "precondition: a cleared arm target must actually be resolved, or this test " + "proves nothing about the thread it resolves on" + ) + assert not any(observed), ( + "the cleared-project arm target resolved ON the event loop thread; it mkdirs " + "and realpaths the workspace root, so it must be offloaded" + ) + assert ( + armed and armed != CWD_CLEARED + ), f"and it must hand back a concrete directory for the arm to record; got {armed!r}" + # A stated project needs no filesystem work, so it must not be offloaded at all. + before = len(observed) + assert await mgr.resolve_arm_cwd("sess-armsite", "/projects/alpha") == "/projects/alpha" + assert len(observed) == before, "a non-empty project must not touch the resolver" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_arming_a_cleared_project_does_no_filesystem_work_on_the_loop( + self, cfg, monkeypatch + ): + """The arm records a resolved directory, and resolving the cleared one hits disk. + + `note_project_change` is called from an async dashboard handler, so doing the + workspace-root stat and realpath inside it blocks the event loop -- the gateway and + its heartbeat with it, on storage that is slow rather than broken. The recording + itself stays synchronous, so nothing can land between the generation bump and the + arm; only the resolution moves off-thread. + """ + import threading + + from kiro_crew import session_allocation as alloc + + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + loop_thread = threading.current_thread() + observed: list[bool] = [] + real_resolved = alloc.resolved_cwd + + def spy(cwd, session_key=None): + if cwd == "" and session_key: + observed.append(threading.current_thread() is loop_thread) + return real_resolved(cwd, session_key) + + monkeypatch.setattr(alloc, "resolved_cwd", spy) + monkeypatch.setattr("kiro_crew.session.resolved_cwd", spy) + + await mgr.note_project_change("sess-armclear", "") + + assert observed, ( + "precondition: arming a CLEARED project must actually resolve the per-session " + "default, or this test proves nothing about where that resolution runs" + ) + assert not any(observed), ( + "the cleared-project arm resolved its directory ON the event loop thread; it " + "stats and realpaths the workspace root, so it must be offloaded" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_the_cleared_cwd_resolution_never_runs_under_the_registry_lock( + self, cfg, monkeypatch + ): + """Resolving the cleared-project default is filesystem work, so it stays off-loop. + + The per-session default stats the configured workspace root and realpaths it. Done + inside ``async with self._lock`` that is synchronous I/O on the event loop with the + registry held, so a stalled mount blocks in the kernel and freezes every session at + once -- the gateway and its heartbeat with them, and the watchdog respawns into the + same condition. The comparison two lines above it already refuses realpath for this + reason; the empty case has to obey the same rule. + """ + import threading + + from kiro_crew import session_allocation as alloc + + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + loop_thread = threading.current_thread() + observed: list[tuple[bool, bool]] = [] + real_resolved = alloc.resolved_cwd + + def spy(cwd, session_key=None): + # Only the keyed EMPTY case does filesystem work; the rest is string handling. + if cwd == "" and session_key: + observed.append( + (threading.current_thread() is loop_thread, boundary._lock.locked()) + ) + return real_resolved(cwd, session_key) + + monkeypatch.setattr(alloc, "resolved_cwd", spy) + + await mgr.get_or_create("sess-loopsafe", cwd="") + mgr.release("sess-loopsafe") + # The REUSE path is the one that runs the claim gate under the registry lock. + await mgr.get_or_create("sess-loopsafe", cwd="") + mgr.release("sess-loopsafe") + + assert observed, ( + "precondition: the cleared-project resolution must actually be reached, or " + "this test proves nothing about where it runs" + ) + on_loop = [o for o in observed if o[0]] + under_lock = [o for o in observed if o[1]] + assert not under_lock, ( + "the cleared-project resolution ran with the REGISTRY LOCK held: synchronous " + "filesystem work there freezes every session at once on a stalled mount" + ) + assert not on_loop, ( + "the cleared-project resolution ran on the EVENT LOOP thread; it stats and " + "realpaths the workspace root, so it must be offloaded" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_cleared_project_resolves_per_session_not_one_shared_dir(self, cfg): + """A cleared project must resolve to the directory the PROVIDER for that key binds. + + The provider factory resolves an empty ``cwd`` to a per-session directory under + ``workspace_root()``, which is a different root from the shared + ``config_dir()/"workspace"``. Answering the shared one for a cleared project has two + consequences: the claim compares against a directory no provider ever binds, so it + never matches its own binding and the slot cold-starts every turn or exhausts its + retry budget; and any caller that BINDS the answer puts sessions meant to be + isolated in one directory, where their relative writes overwrite each other. + """ + from kiro_crew.config.loader import session_default_cwd + from kiro_crew.config.paths import default_workspace_dir, resolved_cwd + + a = resolved_cwd("", "sess-cleared-a") + b = resolved_cwd("", "sess-cleared-b") + + assert a != b, ( + "two sessions with a cleared project must not collapse onto one directory; " + f"both resolved to {a!r}" + ) + assert a == str(session_default_cwd("sess-cleared-a")), ( + "and each must be the SAME directory its own provider binds, or the claim " + f"cannot match its binding; resolved {a!r}" + ) + assert a != str(default_workspace_dir()), ( + "the shared workspace is the wrong answer for a keyed session -- that is the " + "divergence between the two no-cwd fallbacks" + ) + # No key, no answer: guessing the shared root here is what fabricated a directory + # no provider binds, and every caller either holds a key or states a directory. + with pytest.raises(ValueError): + resolved_cwd("") + # A stated directory is unaffected by the key. + assert resolved_cwd(str(self.BETA), "sess-cleared-a") == str(Path(self.BETA)) + + @pytest.mark.asyncio + async def test_a_refused_claim_does_not_spend_the_directory_arm(self, cfg): + """An arm is spent when the claim is ACCEPTED, never one predicate at a time. + + A project-scope agent switch KEEPS the directory, so it arms both halves: the + directory the session is already bound to, and the agent a successor must run. A + cwd-less claim then satisfies the DIRECTORY half by its binding while the identity + half still forces retirement -- so the claim is refused. Spending each half on its + own predicate pops the directory arm on that refused claim, and the SUCCESSOR is + then unguarded: it can bind any directory, nothing refuses it, and its relative + writes land outside the selected project with no recovery path. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-partial") + + await mgr.get_or_create("sess-partial", cwd=self.BETA, agent="oncall") + mgr.release("sess-partial") + + # The switch: SAME directory, different agent -- both halves armed. + mgr.mark_retire_on_next_claim("sess-partial", "/projects/beta", agent="research") + assert ( + armed_cwd(boundary, folded) == self.BETA + ), "precondition: the arm names the directory the live session already holds" + assert armed_agent(boundary, folded) == "research", ( + "precondition: the identity half is armed too, which is what still forces " + "retirement once the directory half is satisfied" + ) + + # A channel turn: states no cwd, so its BINDING satisfies the directory half + # while the identity half cannot be satisfied at all. + await mgr.get_or_create("sess-partial", agent="oncall") + + assert armed_cwd(boundary, folded) == self.BETA, ( + "the directory arm must SURVIVE a claim this frame refused -- spent here, the " + "successor binds any directory unguarded and writes outside the project" + ) + assert ( + armed_agent(boundary, folded) == "research" + ), "and the identity arm with it: the two are spent together or not at all" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_recreated_slot_does_not_inherit_the_previous_slots_arm(self, cfg): + """A new slot on a recycled key must not be forced onto the old project. + + The refused teardown arms the key, and the close that follows runs ``remove``, which + PRESERVES the arm so an evicted start can still retry. But a close ends the slot, so + the next occupant of that name is a different slot with its own project -- and the + surviving arm names a directory it never chose, which every relative write then lands + in. Only ``destroy`` spent the arm, and the close path does not call it. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("dashboard_chat-1") + + await mgr.get_or_create("dashboard_chat-1", cwd=self.BETA) + mgr.release("dashboard_chat-1") + # The refused teardown: arms the key rather than tearing down a streaming turn. + mgr.mark_retire_on_next_claim("dashboard_chat-1", str(self.BETA)) + # The close the user actually performs: preserves the arm by design. + await mgr.remove("dashboard_chat-1") + assert armed_cwd(boundary, folded) == str( + Path(self.BETA) + ), "precondition: remove preserves the arm, which is what makes the leak reachable" + + # A NEW tab minted on the same key, carrying a different project. + mgr.supersede_arm_for_new_slot("dashboard_chat-1") + + assert armed_cwd(boundary, folded) is None, ( + "the previous slot's arm survived into a new slot, which is then forced onto a " + "project it never chose -- every relative write lands in the old directory" + ) + assert armed_agent(boundary, folded) is None, ( + "the identity half leaked too: the new slot would be refused until it runs the " + "previous slot's agent" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_matched_retraction_bumps_the_generation(self, cfg): + """A retraction that proceeds bumps the counter, which is what strands its own start. + + The docstring distinguishes this from the superseded case; both are asserted so the prose + and the code cannot drift apart again. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("dashboard_chat-gen2") + + mgr.mark_retire_on_next_claim("dashboard_chat-gen2", str(self.BETA)) + armed_at = boundary._generation(folded) + assert armed_cwd(boundary, folded) is not None, "precondition: the key is armed" + + # Scoped to the generation this producer was handed: the matched path. + mgr.supersede_arm_for_new_slot("dashboard_chat-gen2", only_generation=armed_at) + + assert boundary._generation(folded) > armed_at, ( + "a retraction that proceeded left the counter alone, so a start already in flight " + "under the old generation still reads as current and is served" + ) + assert armed_cwd(boundary, folded) is None, "and the arm it retracted must be spent" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_superseded_retraction_leaves_the_generation_alone(self, cfg): + """Another producer armed inside the await window, so this retraction must touch nothing. + + Bumping here would invalidate that producer's in-flight start -- the cross-project write + the scoping exists to prevent. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("dashboard_chat-gen3") + + mgr.mark_retire_on_next_claim("dashboard_chat-gen3", str(self.BETA)) + stale_generation = boundary._generation(folded) + # A second producer arms the same key: the resident generation moves on. + mgr.mark_retire_on_next_claim("dashboard_chat-gen3", str(self.ALPHA)) + resident = boundary._generation(folded) + assert resident != stale_generation, "precondition: the generation must have moved" + + mgr.supersede_arm_for_new_slot("dashboard_chat-gen3", only_generation=stale_generation) + + assert ( + boundary._generation(folded) == resident + ), "the superseded path bumped the counter, invalidating the OTHER producer's start" + assert ( + armed_cwd(boundary, folded) is not None + ), "the superseded path dropped an arm that belongs to another producer" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_an_alias_whose_target_differs_does_not_exhaust_cold_start_retries(self, cfg): + """The cold-start stale check must accept the RESOLVED target, not only the alias. + + The retry re-points at the arm through `resolve_runtime_agent`, so it arrives carrying + the TARGET while this check still held the raw ALIAS. For any alias whose binding names + a different agent -- what `resolve_agent_bindings` returns for an ordinary config -- + the two never compare equal, the eviction keeps the arm because the stale branch set + `retire_on_identity_change`, and the retry re-reads the same alias until the budget + runs out and the claim raises instead of starting. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-alias") + + # An ordinary alias: its binding names a DIFFERENT runtime agent than itself. + object.__setattr__( + boundary._deps, + "resolve_runtime_agent", + lambda alias, project=None: "runtime-target" if alias == "team-alias" else alias, + ) + # Armed with NO live session, which is the cold-start shape: the arm records the KEY. + mgr.mark_retire_on_next_claim("sess-alias", str(self.BETA), agent="team-alias") + assert ( + armed_agent(boundary, folded) == "team-alias" + ), "precondition: the arm carries the ALIAS, which is what makes the loop reachable" + + # The cold start arrives carrying the RESOLVED target, exactly as the retry re-points + # it. It must START rather than read itself stale and spend the retry budget. + await mgr.get_or_create("sess-alias", cwd=self.BETA, agent="runtime-target") + await mgr.close_all() + + @pytest.mark.asyncio + async def test_resolving_an_armed_alias_never_loads_config_on_the_event_loop( + self, cfg, monkeypatch + ): + """A cache-miss alias resolve runs off the event loop. + + The load is cheap on a cache hit, but a miss reads the file, parses it and runs the + full schema validation, which on the event loop stalls every other session's turn. + """ + loop_thread = threading.get_ident() + load_threads: list[int] = [] + real_load = KiroCrewConfig.load + + def _recording_load(): + load_threads.append(threading.get_ident()) + return real_load() + + monkeypatch.setattr( + "kiro_crew.session.KiroCrewConfig", SimpleNamespace(load=_recording_load) + ) + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-offload", cwd=self.BETA) + mgr.release("sess-offload") + mgr.mark_retire_on_next_claim("sess-offload", str(self.BETA), agent="team-alias") + await mgr.get_or_create("sess-offload", cwd=self.BETA, agent="team-alias") + + assert load_threads, "the armed alias was never resolved at all" + on_loop = [t for t in load_threads if t == loop_thread] + assert not on_loop, ( + "the config load ran on the event-loop thread, so a cache miss stats, reads, " + f"parses and schema-validates inline; {len(on_loop)} of {len(load_threads)} " + "call(s) were on the loop" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_same_directory_arm_still_refuses_the_flagged_session(self, cfg): + """An IDENTITY change cannot be satisfied by a directory match. + + An agent switch keeps `slot.project` when the agent is project-scope, so the arm it + raises names the directory the live session is ALREADY bound to. Satisfaction is a + directory test, and a cwd-less channel claim cannot state otherwise, so the arm is + met by the very session the switch was replacing -- and the switched-away agent + serves the next turn. `mark_retire_on_next_claim` flags the REGISTERED session, + which is the part a matching directory cannot express; `note_project_change` does + not, which is why the two are not interchangeable at an identity change. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + provider, _, _ = await mgr.get_or_create("sess-ident", cwd=self.BETA) + mgr.release("sess-ident") + first = seen[-1] + + # The agent switch: same project, so the arm names where the session already is. + mgr.mark_retire_on_next_claim("sess-ident", "/projects/beta") + assert armed_cwd(boundary, mgr._fold_key("sess-ident")) == self.BETA, ( + "precondition: the arm names the directory the live session is bound to, " + "which is what makes a directory-only test insufficient here" + ) + + # A channel turn: states no cwd, so nothing in the claim can distinguish it. + second, is_new, _ = await mgr.get_or_create("sess-ident") + + assert is_new is True, ( + "the switched-away agent's session must NOT be reused: its directory matches " + "the arm, so only the identity flag can refuse it" + ) + assert second is not first, "the replacement must be a different provider" + first.shutdown.assert_awaited() + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_cwd_less_claim_satisfies_an_arm_its_binding_already_matches(self, cfg): + """Otherwise the arm is unsatisfiable for channel turns and reuse never returns. + + A channel turn claims with NO cwd -- the dispatch builds its kwargs with `model` + and nothing else -- so it can never state agreement with the armed directory. If + satisfaction requires a stated cwd, the arm survives every such claim: turn after + turn retires and cold-starts, so the change costs a cold start FOREVER instead of + the one it is supposed to cost. The session's own binding has to settle it, and a + session bound to exactly the armed directory IS the successor the arm awaited. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-chan") + + # The dashboard moved the project; the arm names where a successor must land. + await mgr.note_project_change("sess-chan", "/projects/beta") + mgr.mark_retire_on_next_claim("sess-chan", "/projects/beta") + + # First cwd-less claim: nothing registered, so it cold-starts -- and the eager + # spawn roots it at the new project, which is the one cold start being paid for. + _, first_new, _ = await mgr.get_or_create("sess-chan", cwd=self.BETA) + assert first_new is True, "precondition: the first claim after the change is cold" + mgr.release("sess-chan") + + # The SECOND claim is the one under test: a real channel turn, stating no cwd. + _, second_new, _ = await mgr.get_or_create("sess-chan") + + assert second_new is False, ( + "the live session is bound to exactly the armed directory, so a claim that " + "states no cwd must REUSE it -- retiring here churns every channel turn" + ) + assert armed_cwd(boundary, folded) is None, ( + "and the arm must be spent by that reuse, or it refuses the next turn too " + "and the churn is permanent rather than one cold start" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_cancelled_stale_eviction_does_not_strand_the_permit(self, cfg): + """The frame holds the new session's permit, so it must survive a cancellation. + + A stale generation is registered and its permit already taken, then the eviction + AWAITS the registry lock. Cancelled at that await, the eviction never runs, so the + session stays registered -- and without a release the permit it holds is owned by + nobody. Every later turn on the key then parks on that semaphore forever. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + # Armed for a directory this cold start will NOT bind, so its registration is + # judged a stale generation and takes the eviction path. + mgr.mark_retire_on_next_claim("sess-cancel", "/projects/beta") + + async def _cancelled_eviction(key, session): + raise asyncio.CancelledError + + mgr._evict_stale_session = _cancelled_eviction # type: ignore[method-assign] + + # `finally`, not a trailing call: an assertion below can fail, and an early exit + # would leave this manager's cleanup loop running for the rest of the session. + try: + with pytest.raises(asyncio.CancelledError): + await mgr.get_or_create("sess-cancel", cwd="/projects/alpha") + + stranded = boundary._sessions.get("sess-cancel") + assert ( + stranded is not None + ), "precondition: the cancelled eviction leaves the session registered" + assert not stranded.semaphore.locked(), ( + "a cancellation while the eviction waits for the registry lock must not " + "leave the permit held -- nothing else can release it and the key is " + "locked for good" + ) + finally: + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_provider_on_the_abc_default_is_not_read_as_the_workspace(self, cfg): + """The ABC's `cwd` default is `""`, and that means "tracks none", not "workspace". + + A provider that never overrides `cwd` reports the base class's empty string. Read + as a binding it resolves to the default workspace, which then disagrees with every + project-scoped claim and evicts a warm session on every turn -- silently, since a + directory mismatch is exactly what the gate is entitled to act on. A real provider + bound to the workspace reports that concrete path instead, so the empty string can + only mean there is no directory to compare. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + + provider, _, _ = await mgr.get_or_create("sess-abc", cwd="/projects/alpha") + mgr.release("sess-abc") + # Exactly the shape the ABC hands a subclass that does not override `cwd`. + provider.cwd = "" + + _, is_new, _ = await mgr.get_or_create("sess-abc", cwd="/projects/alpha") + assert is_new is False, ( + "a provider reporting the ABC default states no directory, so it must not be " + "read as a MISMATCH against a project-scoped claim -- that evicts a warm " + "session every turn for every provider that never overrode cwd" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_change_landing_during_model_resolution_still_refuses_the_start(self, cfg): + """The snapshot must precede the whole start, not just the factory call. + + Model resolution goes off-loop, so an agent or workspace switch can commit a new + project while a cold start is parked there. A generation read AFTER that await + records the NEW value, so the start compares equal to a key that has already moved + and is served -- and the turn writes relative paths into the previous project. The + snapshot has to be taken before the first await for the comparison to mean + anything. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + resolved = {"n": 0} + original = boundary._deps.session_model + + def _model_then_switch(*args, **kwargs): + # Runs inside the off-loop await, i.e. mid-start: the switch commits its new + # project here, exactly the window the snapshot has to sit in front of. + resolved["n"] += 1 + if resolved["n"] == 1: + # The BOUNDARY's synchronous recorder: this hook is sync by design, and a + # non-empty project needs no off-thread resolution to record. + boundary.note_project_change("sess-midstart", "/projects/beta") + return original(*args, **kwargs) + + # `_deps` is a frozen dataclass, so the hook goes in through object.__setattr__. + object.__setattr__(boundary._deps, "session_model", _model_then_switch) + + provider, _, _ = await mgr.get_or_create("sess-midstart", cwd="/projects/alpha") + + assert resolved["n"] >= 1, "precondition: model resolution ran, so the window opened" + assert len(seen) == 2, ( + "the generation moved while this start was parked off-loop, so it belongs to " + "the superseded project and must be torn down and retried, not served; only " + f"{len(seen)} provider(s) were created, so the pre-change one was handed over" + ) + assert provider.cwd == self.BETA, ( + "and the retry must bind the directory the switch COMMITTED, not the one this " + f"frame was called with; got {provider.cwd!r}" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_rebind_transfers_the_arm_instead_of_duplicating_it(self, cfg): + """A rebound slot must leave NO arm on the key it abandoned. + + The arm map is keyed by STRING and an arm is cleared by the claim that satisfies + it. A slot that rebinds between arming and consuming never sends a claim under + the old key, so an arm merely COPIED to the live key stays behind forever. It is + not inert: a later session registered under that same string -- a channel re-link + reusing the id, a recreated slot of the same name -- reads an arm naming a + directory chosen for a binding that is gone, and is refused or retried + onto the wrong project. So the arm has to MOVE. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + old = mgr._fold_key("sess-abandoned") + new = mgr._fold_key("sess-live") + + mgr.mark_retire_on_next_claim("sess-abandoned", "/projects/beta", agent="research") + assert ( + armed_cwd(boundary, old) == self.BETA + ), "precondition: the producer armed the key it could see" + generation_before = boundary._generation(old) + assert generation_before, "precondition: arming bumped the abandoned key's generation" + + mgr.transfer_retire_arm("sess-abandoned", "sess-live", "/projects/beta") + + assert armed_cwd(boundary, old) is None, ( + "the abandoned key must hold NO arm after the transfer; a copy left there is " + "cleared by nothing, because no claim ever arrives under a key the slot left" + ) + assert ( + armed_agent(boundary, old) is None + ), "the identity half of the same arm must not survive on the abandoned key either" + assert ( + armed_cwd(boundary, new) == self.BETA + ), "the requirement is still owed -- only its address was wrong" + assert armed_agent(boundary, new) == "research", ( + "only the arm states which agent a successor must run, so the identity " + "requirement has to travel with the directory rather than be dropped" + ) + assert boundary._generation(old) == generation_before, ( + "the abandoned key's GENERATION must survive: it is what refuses a start " + "still in flight under the old key, which a rebind makes likely" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_reused_key_after_a_rebind_binds_its_own_project(self, cfg): + """The harm the transfer prevents, end to end. + + A slot arms key A, rebinds to B, and later something registers under A again. If + the rebind left A's arm in place, that claim is decided by a directory chosen for + a slot that has left the key. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + + mgr.mark_retire_on_next_claim("sess-reused", "/projects/beta") + mgr.transfer_retire_arm("sess-reused", "sess-elsewhere", "/projects/beta") + + provider, _, _ = await mgr.get_or_create("sess-reused", cwd="/projects/gamma") + assert provider.cwd == str(Path("/projects/gamma")), ( + "a claim under the reused key asked for gamma; a residual arm from the " + f"abandoned binding decided it instead and gave {provider.cwd!r}" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_newer_change_supersedes_the_earlier_arm(self, cfg): + """Two changes to one key leave ONE answer, and it is the newer one. + + An arm records what a successor must bind at a point in time. If an earlier arm + survives a later change, the claim it decides is sent to a project the user has + already navigated away from -- the same stale-directory harm this guard exists to + prevent, arriving through the guard itself. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-super") + + mgr.mark_retire_on_next_claim("sess-super", "/projects/beta") + first = armed_cwd(boundary, folded) + assert first == self.BETA, "precondition: the first change armed beta" + + # The agent switch commits a new project and resets directly. It records THAT + # directory with the bump, so the earlier beta entry is replaced, not merely aged. + await mgr.note_project_change("sess-super", "/projects/gamma") + + assert armed_cwd(boundary, folded) == str(Path("/projects/gamma")), ( + "an arm from an earlier change must not survive a newer one; retaining beta " + "would bind the next claim to the project the user left" + ) + provider, _, _ = await mgr.get_or_create("sess-super", cwd="/projects/gamma") + assert provider.cwd == str( + Path("/projects/gamma") + ), f"the claim asked for gamma; got {provider.cwd!r}" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_recreated_slot_keeps_its_own_project(self, cfg): + """An arm from a closed slot must not decide a later slot's directory. + + A project change arms the key, the tab closes, and a same-name slot is recreated + against a DIFFERENT project. What stops the dead slot's arm deciding that claim is + NOT the removal -- the arm must survive that, because a start still in flight has + nothing else to retry with. It is that selecting a project is itself a PRODUCER: + every live-slot project change records the directory it committed, so the recreated + slot's own pick replaces the arm. `test_live_slot_project_producers_bump_the_ + generation` is what keeps that true of every producer. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + mgr.mark_retire_on_next_claim("sess-recreated", "/projects/beta") + await mgr.remove("sess-recreated") + + # The recreated slot picks its project, which is a producer -- the path a real slot + # takes through the project endpoint or the workspace switch, never a bare claim. + await mgr.note_project_change("sess-recreated", "/projects/gamma") + provider, _, _ = await mgr.get_or_create("sess-recreated", cwd="/projects/gamma") + + assert provider.cwd == str(Path("/projects/gamma")), ( + "the recreated slot asked for gamma, so serving beta would run its turn in " + f"the closed slot's project; got {provider.cwd!r}" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_evicting_a_dead_successor_leaves_the_arm_for_its_replacement(self, cfg): + """Eviction is not acceptance, so it must not pay the arm off. + + An armed successor can register and then die before it ever serves a turn. If + the eviction that reaps that corpse also spends the arm, the retry starts with + no requirement recorded and binds the switched-away agent -- the exact stale + binding the arm exists to prevent. Only an accepted live claim spends it. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + key = "sess-evict-keeps-arm" + + await mgr.get_or_create(key, cwd=self.BETA) + mgr.release(key) + folded = mgr._fold_key(key) + session = mgr._sessions[folded] + + mgr.mark_retire_on_next_claim(key, str(self.BETA), agent="team-alias") + armed_before = armed_agent(boundary, folded) + assert armed_before is not None, "precondition: the arm is pending" + # Model the SUCCESSOR: a start registering to satisfy the arm carries the default + # False. Leaving it True stages the one case the old guard already covered. + session.retire_on_identity_change = False + + await boundary._evict_stale_session(folded, session) + + assert ( + armed_agent(boundary, folded) == armed_before + ), "the eviction spent the arm, so the replacement start records no requirement" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_non_waiting_claim_reports_busy_taken_during_the_armed_resolve(self, cfg): + """`wait_if_busy=False` must not block when the race lands mid-resolve. + + The busy check runs BEFORE two suspension points -- the cleared-cwd resolve and the + armed alias resolve, both off-thread -- and the acquire runs after them. A competing + turn taking the semaphore in that window leaves a caller that asked never to block + waiting on it, which for a monitor claim means the whole poll stalls behind a turn + instead of reporting the member busy. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + key = "sess-busy-race" + + await mgr.get_or_create(key, cwd=self.BETA) + mgr.release(key) + folded = mgr._fold_key(key) + session = mgr._sessions[folded] + assert not session.semaphore.locked(), "precondition: the claim starts unheld" + + # Armed, so the alias resolve below actually awaits -- that is the window. + mgr.mark_retire_on_next_claim(key, str(self.BETA), agent="team-alias") + + def _resolve_and_lose_the_race(alias, project=None): + # Runs inside the off-thread resolve, i.e. exactly between the busy check and + # the acquire: a competing turn takes the claim here. + session.semaphore._value = 0 + return f"target-of-{alias}" + + object.__setattr__(boundary._deps, "resolve_runtime_agent", _resolve_and_lose_the_race) + + with pytest.raises(SessionBusyError): + await asyncio.wait_for( + boundary._reacquire_and_validate(folded, session, wait_if_busy=False), + timeout=5, + ) + + session.semaphore._value = 1 + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_clear_during_a_cold_start_invalidates_the_late_registration(self, cfg): + """A clear that finds nothing registered must still reach a start in flight. + + The clear path treats "no registered session" as already-cleared and reports + success, but a cold start has CACHED its resume SID before registering, so clearing + the persisted map does not reach it -- it registers afterwards carrying the very + conversation the caller was told was gone. The generation is what a start compares + itself against, so the clear must bump it even when the pop finds nothing. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-cold-clear") + + before = boundary._generation(folded) + assert folded not in mgr._sessions, ( + "precondition: NOTHING registered -- this is the cold-start window, and a clear " + "here reports success without popping anything" + ) + + assert await mgr.discard_conversation("sess-cold-clear", skip_if_busy=True) is True, ( + "precondition: an absent session counts as cleared, which is what makes the " + "in-flight start invisible to this path" + ) + + after = boundary._generation(folded) + assert after > before, ( + "the clear reported success without advancing the generation, so a cold start " + "already in flight registers afterwards and restores the conversation the caller " + f"was told was discarded; generation stayed at {before}" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_each_classified_teardown_behaves_as_its_side_declares(self, cfg): + """DRIVES every classified teardown and reads the arm afterwards. + + The source census in `test_only_slot_ending_teardowns_spend_the_retirement_arm` + answers COMPLETENESS -- that no session-ending path is unclassified -- but it reads + the module's text, so a behaviourally-correct refactor can fail it while a wrong one + passes (Design review). This test answers the other half: for each declared side, run + the teardown against a real manager and assert what actually happened to the arm. + Together they mean a new path must be classified AND its classification must be true. + """ + spends = ("destroy", "close_all") + keeps = ("remove", "remove_if_unclaimed", "reset", "discard_conversation") + + for name in spends + keeps: + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + key = f"sess-{name}" + folded = mgr._fold_key(key) + + await mgr.get_or_create(key, cwd="/projects/alpha") + mgr.release(key) + mgr.mark_retire_on_next_claim(key, "/projects/beta") + assert armed_cwd(boundary, folded) is not None, f"precondition: {name} key armed" + + if name == "close_all": + await mgr.close_all() + elif name == "discard_conversation": + await mgr.discard_conversation(key) + elif name == "reset": + await mgr.reset(key) + else: + await getattr(mgr, name)(key) + + still_armed = armed_cwd(boundary, folded) == self.BETA + if name in spends: + assert not still_armed, ( + f"`{name}` is declared spend-side but the arm SURVIVED it -- no successor " + "can arrive on this key, so the entry can never be cleared and the arm " + "map grows without bound" + ) + else: + assert still_armed, ( + f"`{name}` is declared keep-side but the arm was SPENT -- a successor is " + "still coming and now binds the stale project, which is the silent " + "wrong-directory write this change exists to remove" + ) + if name != "close_all": + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_temporary_removal_keeps_both_the_arm_and_the_generation(self, cfg): + """`remove` is cleanup, not the end of the slot: both survive it. + + The arm names the directory a successor must bind, and it is ALSO the retry target + for a start this cleanup evicts -- which has nothing else to bind, since its own + frame carries the pre-change directory. A recreated slot is not misdirected by it, + because selecting a project is itself a producer and replaces the entry. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-drop") + + await mgr.get_or_create("sess-drop", cwd="/projects/alpha") + mgr.release("sess-drop") + mgr.mark_retire_on_next_claim("sess-drop", "/projects/beta") + assert armed_cwd(boundary, folded) is not None, "precondition: the key is armed" + armed_at = boundary._generation(folded) + assert armed_at is not None, "precondition: arming stamped a generation" + + await mgr.remove("sess-drop") + + assert armed_cwd(boundary, folded) == self.BETA, ( + "a transient removal must KEEP the arm: it is also the retry target a start " + "evicted after this cleanup has nothing else to bind" + ) + assert ( + boundary._generation(folded) == armed_at + ), "and the generation must survive too, or a start still in flight is served" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_destroying_a_key_outright_discards_its_arm(self, cfg): + """Permanent destruction retires the successor that would have paid the arm. + + A destroyed key keeps no session and no history, so nothing remains that could + ever bind to the recorded directory and clear the entry. Left armed, the map + grows for the life of the process and a key later recreated on the same name is + refused by an arm no claim can satisfy. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + folded = mgr._fold_key("sess-destroy") + + await mgr.get_or_create("sess-destroy", cwd="/projects/alpha") + mgr.release("sess-destroy") + mgr.mark_retire_on_next_claim("sess-destroy", "/projects/beta") + assert armed_cwd(boundary, folded) is not None, "precondition: the key is armed" + + await mgr.destroy("sess-destroy") + + assert ( + armed_cwd(boundary, folded) is None + ), "destroying a key must discard its arm -- no successor survives to pay it" + assert ( + armed_cwd(boundary, folded) is None + ), "the recorded directory must go with the arm, or the map grows forever" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_the_claim_gate_reads_the_providers_own_workspace_default(self, cfg): + """The gate must resolve the empty case through the provider's own rule. + + Allocation compares a claim naming no directory against what a provider given no + directory binds to. That pairing is now ONE symbol on both sides, so it cannot drift + textually; what remains testable is that the gate reaches it and that the keyless + case -- which would answer a root no provider binds -- is refused rather than guessed. + """ + from kiro_crew.config.loader import session_default_cwd + from kiro_crew.config.paths import default_workspace_dir, resolved_cwd + + mgr = SessionManager(cfg, provider_factory=self._factory([])) + + assert resolved_cwd("", "sess-gate") == str( + session_default_cwd("sess-gate") + ), "the gate must resolve an empty request through the per-session default" + assert resolved_cwd("", "sess-gate") != str( + default_workspace_dir() + ), "and never to the shared root, which no per-session provider binds" + with pytest.raises(ValueError): + resolved_cwd("") + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_landed_teardown_discards_the_arm_it_satisfies(self, cfg): + """The arm is paid by the SUCCESSOR, not by the teardown that removed one session. + + A landed reset removes the session in front of it, but other cold starts bound + to the old directory can still be in flight, so clearing the arm there would + leave them reusable. The arm is therefore retained past the teardown and paid at + the claim boundary instead, by a session provably bound to the requested + directory -- which still keeps the eager respawn from paying a cold start it + exists to hide. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + mgr.mark_retire_on_next_claim("sess-sat", "/projects/gamma") + await mgr.get_or_create("sess-sat", cwd="/projects/beta") + mgr.release("sess-sat") + + applied = await mgr.reset("sess-sat", skip_if_busy=True) + assert applied is True, "precondition: the teardown has to actually land" + assert armed_cwd(boundary, "sess-sat") is not None, ( + "the teardown removed one session, but a cold start bound to the old " + "directory can still be in flight -- the arm must outlive it" + ) + + # The respawn's session must survive its first reuse claim. + await mgr.get_or_create("sess-sat", cwd="/projects/gamma") + mgr.release("sess-sat") + _, is_new, _ = await mgr.get_or_create("sess-sat", cwd="/projects/gamma") + assert is_new is False, "a stale arm must not cold-start a correctly-bound successor" + assert ( + armed_cwd(boundary, "sess-sat") is None + ), "the successor bound to the requested directory pays the arm" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_pinned_session_is_not_reused_even_with_no_cwd_named(self, cfg): + """The pin must hold where the directory comparison structurally cannot. + + This is the CLEARED-project hole. `cwd=slot.project or None` collapses "no + project" into `None`, and a caller naming no directory states no requirement, + so the mismatch test can never fire for a clear -- by construction, not by + oversight. The pin closes it because it does not consult the directory at + all: a session marked at deferral time is refused on the next claim however + that claim names its cwd. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-pinned", cwd="/projects/alpha") + mgr.release("sess-pinned") + + live = mgr._sessions["sess-pinned"] + mgr.mark_retire_on_next_claim("sess-pinned", "/projects/beta") + assert ( + live.retire_on_identity_change is True + ), "a registered session must carry the flag the validity check reads" + + # Exactly the acquisition a turn makes after the project was CLEARED. + _, is_new, _ = await mgr.get_or_create("sess-pinned", cwd=None) + assert is_new is True, ( + "a pinned session must not be reused when the caller names no cwd -- that " + "is the cleared-project case, where no directory requirement exists to " + "refuse the stale binding" + ) + # An unknown key is armed too: the point is that a session need not exist yet. + mgr.mark_retire_on_next_claim("no-such-key", "/projects/beta") + assert armed_cwd(mgr._allocation_boundary(), "no-such-key") is not None + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_non_canonical_cwd_does_not_evict_its_own_binding(self, cfg): + """A consistent caller must never mismatch itself. + + The binding is `str(Path(work_dir))`, so it has already lost a trailing + slash. Comparing a raw caller string against it makes "/p/a/" disagree with + its own binding "/p/a" and cold-start on EVERY turn -- silent, with no error, + and now on every reuse path rather than only the dashboard's realpath'd one. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-slash", cwd="/projects/alpha") + mgr.release("sess-slash") + + _, is_new, _ = await mgr.get_or_create("sess-slash", cwd="/projects/alpha/") + assert is_new is False, ( + "a trailing slash names the same directory, so it must reuse -- evicting " + "here churns a warm session on every turn with nothing reported" + ) + assert len(seen) == 1, f"expected no respawn, providers spawned at: {seen}" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_non_canonical_BINDING_also_does_not_evict(self, cfg): + """Normalizing one side is not enough -- the asymmetry is the defect. + + `bound_cwd` is whatever the provider reports. The ACP provider happens to + report `str(Path(...))`, but that is a property of one implementation, not a + guarantee of the attribute: any other provider, or one reporting a path it + was handed verbatim, states its binding un-normalized. Comparing a normalized + request against an un-normalized binding then mismatches on a difference that + names the same directory, and evicts a warm session every single turn. + + Platform-independent instance of the failure that showed up as two Windows + reds, where `Path` also rewrites the separators and so made every reuse on + this path evict. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-bind", cwd="/projects/alpha") + mgr.release("sess-bind") + + # The provider now states its binding in a non-canonical but equivalent form. + live = mgr._sessions["sess-bind"] + type(live.provider).cwd = property(lambda self: "/projects/alpha//") + + _, is_new, _ = await mgr.get_or_create("sess-bind", cwd="/projects/alpha") + assert is_new is False, ( + "an un-normalized BINDING names the same directory, so it must reuse -- " + "normalizing only the requested side leaves the comparison asymmetric and " + "evicts a warm session on every turn" + ) + assert len(seen) == 1, f"expected no respawn, providers spawned at: {seen}" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_cancelled_eviction_still_releases_the_permit(self, cfg): + """A wedged permit is worse than any window it closes. + + The eviction awaits the registry lock, so a cancellation can land inside it. + Without a `finally`, the release is skipped and the permit is held forever -- + every later turn on that key hangs, with no path back. Releasing is safe even + when the eviction was interrupted before popping, because the check is + IDEMPOTENT: the directory still does not match, so the next claim re-detects + it and evicts again. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-cancel", cwd="/projects/alpha") + mgr.release("sess-cancel") + live = mgr._sessions["sess-cancel"] + boundary = mgr._allocation_boundary() + + async def _cancelled_evict(key, session): + raise asyncio.CancelledError + + with patch.object(boundary, "_evict_stale_session", new=_cancelled_evict): + with pytest.raises(asyncio.CancelledError): + await boundary._reacquire_and_validate("sess-cancel", live, cwd="/projects/beta") + + assert not live.semaphore.locked(), ( + "a cancellation inside the eviction must NOT leave the permit held -- " + "that wedges the key for every later turn" + ) + # Idempotent: the mismatch is still there, so the next claim evicts. + _, is_new, _ = await mgr.get_or_create("sess-cancel", cwd="/projects/beta") + assert is_new is True, "the next claim must re-detect the mismatch and evict" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_the_eviction_lands_before_the_permit_is_released(self, cfg): + """Releasing the permit first would hand a racing acquirer a doomed provider. + + The validation runs with the permit held, so on a mismatch the session is + still registered. Releasing before the eviction leaves a registered session + with a FREE permit: another acquirer wins it, starts a command, and the + eviction then shuts that provider down underneath it. Popping while the + permit is held means the racing acquirer finds no entry and cold-starts, so + the provider is unreachable by the time it is torn down. + + Only a moved directory can expose this window -- a session whose identity + already moved is not the registry occupant, and a dead process has + nothing usable to hand out. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-order", cwd="/projects/alpha") + mgr.release("sess-order") + + observed: list = [] + # Patched on the ALLOCATION BOUNDARY, not on the manager: the validation + # calls its own `self._evict_stale_session`, while the manager's method is a + # separate delegating wrapper the CALLER uses afterwards. Patching the + # wrapper would only ever observe that later call, which by design finds + # nothing left to do -- and would read as the fix being absent. + boundary = mgr._allocation_boundary() + real_evict = boundary._evict_stale_session + + async def _spy(key, session): + observed.append( + { + "permit_held": session.semaphore.locked(), + "still_registered": boundary._sessions.get(key) is session, + } + ) + return await real_evict(key, session) + + with patch.object(boundary, "_evict_stale_session", new=_spy): + await mgr.get_or_create("sess-order", cwd="/projects/beta") + + assert observed, "precondition: the eviction path must have been reached at all" + assert observed[0]["permit_held"] is True, ( + "the eviction must run while the permit is still HELD; releasing first " + "lets another acquirer claim a provider this eviction then shuts down " + "mid-command" + ) + assert observed[0]["still_registered"] is True, ( + "and it must be the eviction that removes the registry entry, so the " + "pop and the release cannot be interleaved by a racing acquirer" + ) + assert seen[0].shutdown.await_count == 1, "torn down exactly once" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_streaming_session_on_another_project_is_not_torn_down(self, cfg): + """The eviction must never land under a turn that is still streaming. + + A deferred project change refuses precisely because a channel turn holds + this session, and that turn then reaches an acquisition for the NEW + directory. Evicting on the directory mismatch there would shut the provider + down underneath the reply being streamed -- moving the exact harm the + deferral prevents one layer down. The check therefore runs only with the + semaphore held, so a busy session is left alone until its turn ends. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-busy", cwd="/projects/alpha") + # NOT released: the semaphore stays held, which is what "a turn is + # streaming on this session" looks like to the registry. + live = mgr._sessions["sess-busy"] + assert live.semaphore.locked(), "precondition: the turn must hold the semaphore" + + moved = asyncio.create_task(mgr.get_or_create("sess-busy", cwd="/projects/beta")) + await asyncio.sleep(0.05) + + assert not moved.done(), ( + "the acquisition for the new project must WAIT on the streaming turn, " "not evict it" + ) + seen[0].shutdown.assert_not_awaited() + assert mgr._sessions.get("sess-busy") is live, ( + "a session with a turn still streaming must not be removed from the " + "registry: the eviction would tear that reply down mid-stream" + ) + + moved.cancel() + try: + await moved + except asyncio.CancelledError: + pass + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_live_session_bound_to_another_project_is_not_reused(self, cfg): + """The alternative is silently running this turn's writes in the old project.""" + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-cwd", cwd="/projects/alpha") + mgr.release("sess-cwd") + + _, is_new, _ = await mgr.get_or_create("sess-cwd", cwd="/projects/beta") + + assert is_new is True, ( + "a session bound to /projects/alpha must not serve a turn that asked for " + "/projects/beta -- its relative writes would land in alpha" + ) + assert seen[0].cwd == self.ALPHA, "precondition: the first bind was recorded" + seen[0].shutdown.assert_awaited_once() + assert seen[-1].cwd == self.BETA, "the replacement binds at the requested dir" + await mgr.close_all() + + @pytest.mark.asyncio + async def test_the_same_project_still_reuses_the_live_session(self, cfg): + """The check must not churn the ordinary case, which is every other turn.""" + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-cwd", cwd="/projects/alpha") + mgr.release("sess-cwd") + + _, is_new, _ = await mgr.get_or_create("sess-cwd", cwd="/projects/alpha") + + assert is_new is False, "an unchanged project must reuse, or every turn cold-starts" + seen[0].shutdown.assert_not_awaited() + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_caller_naming_no_project_does_not_evict(self, cfg): + """No ``cwd`` states no requirement, so it cannot contradict a binding. + + Most acquisitions pass none; reading that as a mismatch would tear down a + session that is serving its callers correctly. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-cwd", cwd="/projects/alpha") + mgr.release("sess-cwd") + + _, is_new, _ = await mgr.get_or_create("sess-cwd") + + assert is_new is False, "a caller expressing no directory must not evict" + seen[0].shutdown.assert_not_awaited() + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_provider_with_no_readable_binding_is_not_evicted(self, cfg): + """An unreadable binding is not a disagreement. + + A provider that does not track a real directory string reports nothing to + contradict the request, and evicting on that would churn every reuse + instead of protecting anything. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + await mgr.get_or_create("sess-cwd", cwd="/projects/alpha") + mgr.release("sess-cwd") + seen[0].cwd = None + + _, is_new, _ = await mgr.get_or_create("sess-cwd", cwd="/projects/beta") + + assert is_new is False, "an unreadable binding must not be read as a mismatch" + await mgr.close_all() + + def test_the_real_acp_provider_reports_its_directory_through_public_cwd(self): + """The capability must land on the public property, not a private probe. + + Reuse validation reads ``provider.cwd``. Without an override the ACP session + provider inherits the ABC's "" default, ``bound_cwd`` is falsy, and the directory + comparison is skipped for exactly those sessions -- the stale-cwd bug returns while + the suite stays green. Asserted against the REAL class rather than a mock, so + renaming either backing attribute fails here instead of silently disabling the + check. Which of the two wins is covered in ``test_acp_session_provider``. + """ + from kiro_crew.acp.session_provider import AcpSessionProvider + + assert "cwd" in vars(AcpSessionProvider), ( + "AcpSessionProvider must OVERRIDE the public `cwd` property; inheriting " + "the LLMProvider default returns '' and skips cwd validation" + ) + + provider = object.__new__(AcpSessionProvider) + provider._runtime = SimpleNamespace(_work_dir=Path("/projects/alpha")) + # No per-session directory recorded: the single-session case, where the runtime's + # own directory IS this session's. + provider._handle = SimpleNamespace(_bound_cwd="") + + assert provider.cwd == str(Path("/projects/alpha")), ( + "the public cwd must report a real directory, so a rename of either backing " + "attribute breaks this test rather than degrading cwd to ''" + ) + + @pytest.mark.asyncio + async def test_a_replaced_claimant_does_not_consume_the_successors_arm(self, cfg): + """The arm belongs to the registered session, not to whoever claims first. + + Two cold starts race a project change. The OLD claimant reaches this frame + holding a session the registry has already replaced. If it consumes the arm, + the SUCCESSOR -- the session the arm exists to invalidate -- is left reusable + and keeps serving turns bound to the old directory. Exercises two real + generations rather than mocking a counter, so it fails against the bug. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + await mgr.get_or_create("sess-gen", cwd="/projects/alpha") + mgr.release("sess-gen") + old_session = boundary._sessions["sess-gen"] + + # Generation 2 registers, replacing generation 1 in the registry. + new_session = replace(old_session) + boundary._sessions["sess-gen"] = new_session + + # The arm is raised for the key while generation 2 is the live entry. + mgr.mark_retire_on_next_claim("sess-gen", "/projects/alpha") + assert armed_cwd(boundary, "sess-gen") is not None + + # The OLD claimant now reaches the claim boundary. + await boundary._reacquire_and_validate( + "sess-gen", old_session, cwd="/projects/alpha", wait_if_busy=True + ) + + assert armed_cwd(boundary, "sess-gen") is not None, ( + "a claimant whose session was already replaced must NOT consume the arm; " + "spending it here leaves the successor reusable on the old directory" + ) + await mgr.close_all() + + @pytest.mark.asyncio + async def test_a_session_binding_its_directory_elsewhere_is_still_validated(self, cfg): + """An ACP-shaped provider's directory is still compared on reuse. + + The ACP provider keeps its real directory in ``_work_dir`` and publishes it + through the ``cwd`` property the ABC defines. Reuse validation reads that + public capability, so a replan under a workspace override must not hand back + a session still bound to the OLD directory -- which would send the turn's + relative writes there. + """ + seen: list = [] + + def acp_shaped(session_key=None, agent=None, channel_id=None, cwd=None, **kwargs): + m = AsyncMock() + m.start = AsyncMock() + m.shutdown = AsyncMock() + # Mirrors AcpSessionProvider: the directory lives in `_work_dir` and is + # published through the public `cwd` property that validation reads. + m._work_dir = Path(cwd) if cwd else None + m.cwd = str(m._work_dir) if cwd else "" + m.context_usage_pct = MagicMock(return_value=0.0) + m.is_alive = MagicMock(return_value=True) + m.is_process_alive = MagicMock(return_value=True) + m.has_active_turn = MagicMock(return_value=False) + m.runtime_info = MagicMock(return_value=(None, None)) + seen.append(m) + return m + + mgr = SessionManager(cfg, provider_factory=acp_shaped) + await mgr.get_or_create("sess-acp", cwd="/projects/alpha") + mgr.release("sess-acp") + + _, is_new, _ = await mgr.get_or_create("sess-acp", cwd="/projects/beta") + + assert is_new is True, "a session on /projects/alpha must not serve /projects/beta" + assert len(seen) == 2, f"expected a respawn on the new directory, spawned: {seen}" + assert seen[1]._work_dir == Path("/projects/beta"), "respawn binds the NEW directory" + assert seen[0].shutdown.await_count == 1, "the stale provider is torn down once" + await mgr.close_all() + + class TestIsProviderAlive: """Tests for is_provider_alive preferring is_process_alive over is_alive.""" @@ -2644,6 +4477,78 @@ async def test_teardown_does_not_leave_a_suppression_for_a_reused_key(self, cfg) assert mgr.consume_replay_suppression("k1") is False +class TestArmIsReclaimedOnFinalSlotTeardown: + """The arm maps must not grow without bound across closed slots.""" + + BETA = str(Path("/projects/beta").resolve()) + + def _factory(self, seen: list): + def _make(*a, **k): + p = _mock_provider_factory()(*a, **k) + seen.append(p) + return p + + return _make + + @pytest.mark.asyncio + async def test_a_closed_slots_arm_is_released_not_left_resident(self, cfg): + """`remove` keeps the arm on purpose, so the FINAL teardown has to reclaim it. + + Nothing else does: a slot closed and never recreated left its cwd, agent and + generation entries resident until process exit, so a long-lived gateway accumulated + one set per transient key that ever saw a project or agent change. Observed through + BEHAVIOUR rather than the maps: after the reap a claim naming any directory is + served as-is, because no arm remains to refuse it. + """ + seen: list = [] + mgr = SessionManager(cfg, provider_factory=self._factory(seen)) + boundary = mgr._allocation_boundary() + + mgr.mark_retire_on_next_claim("dashboard:chat-gone", "/projects/beta", agent="a1") + assert armed_cwd(boundary, "dashboard:chat-gone") is not None, "precondition: armed" + + await mgr.remove("dashboard:chat-gone") + assert armed_cwd(boundary, "dashboard:chat-gone") is not None, ( + "precondition: `remove` must still PRESERVE the arm -- it is owed to a retry, " + "and a reaper that piggybacked on it would break that contract" + ) + + mgr.supersede_arm_for_new_slot("dashboard:chat-gone") + + assert armed_cwd(boundary, "dashboard:chat-gone") is None, ( + "the closed slot's armed directory is still resident, so a long-lived gateway " + "accumulates one entry per transient key that ever saw a project change" + ) + assert ( + armed_agent(boundary, "dashboard:chat-gone") is None + ), "the closed slot's armed agent is still resident" + # And the released key serves a fresh claim rather than being refused by a leftover. + _, is_new, _ = await mgr.get_or_create("dashboard:chat-gone", cwd="/projects/alpha") + mgr.release("dashboard:chat-gone") + assert is_new is True + + @pytest.mark.asyncio + async def test_the_reap_bumps_the_generation_rather_than_clearing_it(self, cfg): + """Clearing would let a pre-close start compare equal to a fresh key's zero. + + Same reason `supersede_arm_for_new_slot` bumps: a start still in flight from the + closed slot carries the old generation, and a zeroed counter would make it look + current to the next occupant of that name. + """ + mgr = SessionManager(cfg, provider_factory=_mock_provider_factory()) + boundary = mgr._allocation_boundary() + mgr.mark_retire_on_next_claim("dashboard:chat-gen", "/projects/beta") + before = boundary._generation(mgr._fold_key("dashboard:chat-gen")) + + mgr.supersede_arm_for_new_slot("dashboard:chat-gen") + + after = boundary._generation(mgr._fold_key("dashboard:chat-gen")) + assert after == before + 1, ( + "the reap cleared or ignored the generation instead of bumping it, so an " + f"in-flight start from the closed slot can bind to a fresh key; {before}->{after}" + ) + + class TestDiscardConversation: """Tests for discard_conversation() — the poisoned-conversation escape. @@ -2668,6 +4573,25 @@ async def test_discard_shuts_down_and_clears_only_sid(self, cfg): mock_delete.assert_not_called() assert not mgr.has_session("k1") + @pytest.mark.asyncio + async def test_an_absent_key_is_not_reported_as_a_busy_refusal(self, cfg): + """False means a busy REFUSAL and nothing else -- an absent key answers True. + + Callers branch on this: a False that could also mean "no session here" would make + every `not discarded` site answer `409 turn_in_flight` for a key with nothing to + clear. The single `return False` is guarded on a LIVE session holding its + semaphore, so the absent case falls through to the teardown path. Any change that + makes this answer False re-introduces the ambiguity those callers would then have + to re-probe for. + """ + mgr = SessionManager(cfg, provider_factory=_mock_provider_factory()) + assert not mgr.has_session("never-registered") + + assert await mgr.discard_conversation("never-registered", skip_if_busy=True) is True, ( + "an absent key reported a busy refusal, so a caller cannot tell it apart from a " + "live turn and answers 409 for a channel that has nothing to clear" + ) + @pytest.mark.asyncio async def test_skip_if_busy_refuses_while_a_turn_holds_the_semaphore(self, cfg): """The guard reads the SEMAPHORE, which is why it has to live here. diff --git a/test/test_session_control.py b/test/test_session_control.py index def927e0c9e..f9c0e3aa452 100644 --- a/test/test_session_control.py +++ b/test/test_session_control.py @@ -4068,3 +4068,63 @@ def _check(): # synchronous by contract — no await before the pop # second retirement is needed because the check itself suspends nothing. assert order == ["retire", "check"], order assert slot.key not in state._slots # closed + + +def test_a_final_close_reaps_the_slots_retirement_arm(tmp_path, monkeypatch): + """`remove` preserves the arm, so the close is the seam that has to reclaim it. + + Without this the entries for a slot that is closed and never recreated stay resident + until process exit -- one set per transient key that ever saw a project or agent change. + Asserted on the CLOSE path, not on the reaper in isolation, because the leak is the + missing call rather than a missing verb. + """ + from kiro_crew.dashboard import chat_handlers + + state = _make_state(tmp_path) + slot = state.get_or_create_slot("chat-reap") + monkeypatch.setattr(chat_handlers, "_retire_slot_nudge_loop", AsyncMock(return_value=None)) + state.sessions = MagicMock() + state.sessions.remove = AsyncMock(return_value=None) + + asyncio.run(chat_handlers.close_slot(state, slot, slot.key)) + + state.sessions.supersede_arm_for_new_slot.assert_called_once_with("dashboard:chat-reap") + + +def test_the_reap_spares_a_replacement_slots_arm(tmp_path, monkeypatch): + """The reap must re-check ownership AFTER the awaited remove, not before it. + + `_slot_still_ours` is answered before `sessions.remove`, and that await is exactly the + window in which a same-key recreate lands and arms its OWN project. Reaping on the stale + verdict then erases the REPLACEMENT's arm, so its retirement retry registers against the + superseded project -- the wrong-cwd corruption this change exists to remove, reintroduced + by the reclaim added for the arm-map leak. + """ + from kiro_crew.dashboard import chat_handlers + + state = _make_state(tmp_path) + slot = state.get_or_create_slot("chat-race") + monkeypatch.setattr(chat_handlers, "_retire_slot_nudge_loop", AsyncMock(return_value=None)) + reaped: list[str] = [] + sessions = MagicMock() + sessions.supersede_arm_for_new_slot = lambda key: reaped.append(key) + + async def _recreate_during_remove(_key): + # A same-key recreate landing inside the await, the way POST /api/chat does: the + # replacement is a DIFFERENT slot object registered under the same name. + state._slots.pop("chat-race", None) + state.get_or_create_slot("chat-race") + # The MINT legitimately supersedes the previous occupant's arm through this same + # verb, so only what follows can be the close's own reap. + reaped.clear() + return None + + sessions.remove = AsyncMock(side_effect=_recreate_during_remove) + state.sessions = sessions + + asyncio.run(chat_handlers.close_slot(state, slot, slot.key)) + + assert reaped == [], ( + "the close reaped an arm after a replacement slot took the key, so the " + f"replacement's own armed project is gone and its retry binds the old one; {reaped}" + ) diff --git a/test/test_session_pool.py b/test/test_session_pool.py index b7f92711d0c..9b9166a0216 100644 --- a/test/test_session_pool.py +++ b/test/test_session_pool.py @@ -14,6 +14,7 @@ import pytest from kiro_crew.acp.session_handle import WatchdogSettings +from kiro_crew.config.paths import CWD_CLEARED @pytest.fixture(autouse=True) @@ -513,6 +514,81 @@ async def test_skips_pool_when_cwd_set(self): factory.assert_called_once() assert factory.call_args.kwargs.get("cwd") == "/Users/alice/workspace/proj" + @pytest.mark.asyncio + async def test_explicit_clear_skips_pool_while_no_preference_still_claims(self): + """An explicitly CLEARED project cold-starts; ``cwd=None`` still reaches the pool. + + ``cwd`` is side-dependent and both spellings are falsy. ``None`` states no + preference, so the pool's shared binding is fine, but CWD_CLEARED says the project + was cleared and the factory will bind ``session_default_cwd(key)`` -- a per-session + directory no shared pooled child is sitting in. Folding the two together let a + cleared turn claim a warm provider rooted in the pool's workspace, so its relative + writes landed there. The second half is the positive control: a guard that bypassed + the pool for every caller would satisfy the first assertion on its own. + """ + mgr, factory = _make_manager(pool_agent="kirocrew") + mgr._drain_and_claim = AsyncMock(return_value=None) + + await mgr.get_or_create("cleared-key", agent="kirocrew", cwd=CWD_CLEARED) + mgr._drain_and_claim.assert_not_awaited() + + await mgr.get_or_create("no-pref-key", agent="kirocrew") + mgr._drain_and_claim.assert_awaited() + + @pytest.mark.asyncio + async def test_an_explicit_clear_refuses_the_stored_resume_sid(self): + """A cleared project must COLD-START, not ``session/load`` the conversation it dropped. + + Bypassing the warm pool only stops the claim taking a shared child; the stored SID is a + separate path, so the turn still resumed the very conversation the clear was asked to + leave behind. The second half is the positive control: a slot stating no preference + keeps its resume, so a guard that simply refused every SID would pass the first half. + """ + mgr, factory = _make_manager(pool_agent="kirocrew") + mgr._drain_and_claim = AsyncMock(return_value=None) + + with patch.object(mgr._session_map, "get", return_value="sid-abc"): + with patch.object(mgr._session_map, "clear_sid") as cleared: + await mgr.get_or_create("cleared-key", agent="kirocrew", cwd=CWD_CLEARED) + cleared.assert_called_once_with("cleared-key") + assert factory.call_args.kwargs.get("cwd") == CWD_CLEARED + + with patch.object(mgr._session_map, "clear_sid") as kept: + await mgr.get_or_create("no-pref-key", agent="kirocrew") + kept.assert_not_called() + + @pytest.mark.asyncio + async def test_a_discarded_conversation_stays_discarded_through_an_armed_retry(self): + """A discard must drop the SID even when the arm still names a real directory. + + A discard bumps the generation and deliberately arms NO directory -- but it does not + reset one an earlier project change left resident, so the arm can name a resolved path + while the conversation it guards was thrown away. The stale retry then states that path + as its cwd, which is not ``CWD_CLEARED``, so the resume guard does not fire and the + discarded conversation is served again. The clear the discard performs itself is no + defence: it runs outside the registry lock, so this retry can read the map first. + """ + mgr, _factory = _make_manager(pool_agent="kirocrew") + arms = mgr._allocation_boundary() + + arms.note_project_change("resurrect-key", "/Users/alice/proj") + arms.note_conversation_discarded("resurrect-key") + arm = arms._arm_if_any(mgr._fold_key("resurrect-key")) + + assert arm is not None and arm.cwd is not None, ( + "the arm states no directory, so this asserts nothing: the resurrection needs a " + "leftover path for the retry to pass instead of the cleared sentinel" + ) + assert arm.requires_sid_clear, ( + "the discard left no provenance, so a retry reading only the directory cannot tell " + "this conversation was thrown away" + ) + + arm.spend() + assert not arm.requires_sid_clear, ( + "provenance outliving its episode makes the NEXT conversation's SID the casualty" + ) + @pytest.mark.asyncio async def test_claims_pool_with_model_override_and_switches(self): """get_or_create claims pool even with model_override, then calls set_model.""" @@ -1207,6 +1283,64 @@ async def test_reload_cancels_old_health_task(self): # --------------------------------------------------------------------------- +class TestTheReuseComparisonSurvivesASymlinkedRoot: + """`_reacquire_and_validate` compares the claim against `provider.cwd` on EVERY reuse. + + It normalizes both sides through `resolved_cwd` and deliberately does not realpath, + because a filesystem call would run under the registry lock. That only works if the + normalization is symmetric: if one side arrives raw, a directory spelled with a + trailing separator reads as a DIFFERENT directory, `cwd_moved` fires on every turn, + and the slot cold-starts each time or exhausts its retry budget -- silently, since an + eviction is a legitimate outcome and nothing errors. + + Driven on a real symlink because that is the case the lane named: a symlinked or + network workspace root is where a realpath-based comparison would disagree with the + spelling the session was opened under. Neither side realpaths, so the link and its + TARGET are different directories here -- a claim naming the target must not be served + by the link's session, which is why that case asserts a move. + """ + + @pytest.mark.asyncio + async def test_restating_the_same_symlinked_directory_does_not_evict(self, tmp_path): + from kiro_crew.session_allocation import cwd_moved_for_reuse + + real = tmp_path / "real-root" + real.mkdir() + link = tmp_path / "linked-root" + link.symlink_to(real, target_is_directory=True) + assert link.is_symlink(), "the fixture is not a symlink, so this proves nothing" + + # `provider.cwd` reports the directory the session was OPENED with, so a session on a + # symlinked root reports the link -- not its target. + bound = str(link) + default = str(tmp_path / "per-session-default") + + for spelling in (str(link), str(link) + "/", str(link) + "//"): + assert cwd_moved_for_reuse(bound, spelling, default) is False, ( + f"restating the same directory as {spelling!r} read as a MOVE, so every reuse " + "evicts and the slot cold-starts each turn" + ) + + # The BOUND side must be normalized too, and this half is what detects an + # asymmetric fix: a trailing or doubled separator is the same directory. + for reported in (str(link) + "/", str(link) + "//", str(link) + "/."): + assert cwd_moved_for_reuse(reported, str(link), default) is False, ( + f"a session bound as {reported!r} read as MOVED against the same directory, so " + "only one side is normalized and every reuse evicts" + ) + + # A claim stating NO requirement never moves, whatever the binding is. + assert cwd_moved_for_reuse(bound, None, default) is False + + # Negative half: a different directory must still evict, or the assertions above + # pass for a comparison that can never fire. The symlink TARGET counts too. + assert cwd_moved_for_reuse(bound, str(tmp_path / "elsewhere"), default) is True + assert cwd_moved_for_reuse(bound, str(real), default) is True + # A provider tracking no directory string has nothing to disagree with. + assert cwd_moved_for_reuse("", str(link), default) is False + assert cwd_moved_for_reuse(None, str(link), default) is False + + class TestRefreshDefaultsSparesLiveSessions: """``agent.model`` / ``agent.reasoning_effort`` are defaults: they apply to the NEXT session. Adopting them must not shut down providers that are diff --git a/test/test_side.py b/test/test_side.py index 32860caf2ae..e72d15692da 100644 --- a/test/test_side.py +++ b/test/test_side.py @@ -1220,6 +1220,71 @@ async def _fake_get_or_create(key, **kwargs): assert parent._side.binding == ("kirocrew--readonly", proj_a, "d" * 64) +@pytest.mark.asyncio +async def test_a_cleared_project_is_not_the_directory_the_side_turn_spawns_in( + tmp_path, monkeypatch +): + """The turn's one project reading goes through ``claim_cwd``, so a cleared project yields + ``CWD_CLEARED`` rather than the path still on the slot. + + Both properties are load-bearing and neither implies the other. Reading the raw field would + spawn the side session in a directory the user cleared, whose stored binding is already + invalid; reading ``claim_cwd`` again at the spawn instead of reusing the snapshot would let a + project change between the shadow check and the spawn, which is what the test above forbids. + Only sourcing the single snapshot from ``claim_cwd`` satisfies both. + + ``CWD_CLEARED`` is the empty string and ``None`` is not the same value: a never-scoped slot + states nothing and keeps the warm pool, while a cleared one must block it. So this asserts the + exact value, not merely falsiness. + """ + from kiro_crew.config.paths import CWD_CLEARED + + state = _make_state(tmp_path) + _capture_broadcasts(state) + parent = state.get_or_create_slot("parent") + stale = str(tmp_path / "proj-cleared") + parent.project = stale + parent.project_cleared = True + parent._side = SideState(open=True, created_at="2026-01-01T00:00:00Z") + parent._side.append_user(_SIDE_QUESTION) + parent._side.last_run_id = "run-cleared" + parent._side.is_complete = False + derived_for: list[str | None] = [] + + def _publish(base_name: str, project_dir: str | None = None): + from kiro_crew.dashboard.side_readonly_spec import PublishedSpec + + derived_for.append(project_dir) + return PublishedSpec(name=f"{base_name}--readonly", digest="e" * 64) + + monkeypatch.setattr("kiro_crew.dashboard.handlers.side.publish_readonly_spec", _publish) + created: list[dict] = [] + + async def _fake_get_or_create(key, **kwargs): + created.append(kwargs) + return MagicMock(), True, False + + state.sessions.get_provider = MagicMock(return_value=None) + state.sessions.get_or_create = _fake_get_or_create + state.sessions.release = MagicMock() + monkeypatch.setattr( + "kiro_crew.dashboard.handlers.side.stream_and_collect", + AsyncMock(return_value=_SIDE_ANSWER), + ) + + await _run_side_turn(state, parent, "run-cleared", _SIDE_QUESTION, is_first_turn=True) + + assert created, "no session was created, so the spawn cwd was never stated" + assert created[0]["cwd"] == CWD_CLEARED, ( + f"the side turn spawned in {created[0]['cwd']!r}; a cleared project must state " + f"CWD_CLEARED, never the path still on the slot" + ) + assert created[0]["cwd"] != stale + assert derived_for == [ + CWD_CLEARED + ], "the shadow check must run against the same reading the spawn uses" + + @pytest.mark.asyncio async def test_a_close_during_acquisition_destroys_the_acquired_session( tmp_path, monkeypatch, _published_readonly_spec diff --git a/test/test_spec_builder_routes_coverage.py b/test/test_spec_builder_routes_coverage.py index fe6e0ee6266..ade0be0b2e5 100644 --- a/test/test_spec_builder_routes_coverage.py +++ b/test/test_spec_builder_routes_coverage.py @@ -1999,6 +1999,42 @@ async def test_a_failing_restore_leaves_the_app_working(self, tmp_path): slot = await r._ensure_worker_slot(state, "demo", _entry(tmp_path / "spec")) assert slot is not None + @pytest.mark.asyncio + async def test_re_scoping_a_cleared_slot_hands_the_turn_the_spec_directory(self, tmp_path): + """A slot cleared EARLIER must not run its spec turn in the default workspace. + + `claim_cwd` reads the cleared MARKER before the project, so assigning a directory + without retiring the marker leaves the accessor answering CWD_CLEARED -- and + `chat_runner` passes exactly that accessor as the turn's cwd. The marker survives a + restart (persistence writes it unconditionally and four restore sites read it back), + so a cleared-then-re-scoped worker writes every relative path into the wrong tree. + + Driven through the REAL slot: the double in this module has neither the marker nor + the accessor, so it could not tell the fixed code from the broken code. + """ + from kiro_crew.dashboard.state import _ChatSlot + + spec_dir = tmp_path / "spec" + spec_dir.mkdir(parents=True, exist_ok=True) + slot = _ChatSlot("spec-builder-demo") + slot._app = r.APP_NAME + # Cleared before this spec was opened, then restored from persisted meta. + slot.project = "" + slot.project_cleared = True + state = _State() + state._slots["spec-builder-demo"] = slot + + with _no_rehydrate(): + got = await r._ensure_worker_slot(state, "demo", _entry(spec_dir)) + + assert got is not None, "the slot was refused, so this test proves nothing" + # `_entry` scopes the worker to the spec's WORKING dir, which is `spec_dir.parent`. + assert got.project == str(tmp_path), f"project not re-scoped; got {got.project!r}" + assert got.claim_cwd == str(tmp_path), ( + "the turn would run in the default workspace and misplace every relative write; " + f"claim_cwd={got.claim_cwd!r}" + ) + @pytest.mark.asyncio async def test_a_slot_owned_by_another_app_is_refused_not_taken_over(self, tmp_path): state = _State(**{"spec-builder-demo": _Slot("spec-builder-demo", app="issue-radar")}) diff --git a/test/test_subagent_delivery_ttl_anchor.py b/test/test_subagent_delivery_ttl_anchor.py index 17552b03aaa..f3aaf7a7c5d 100644 --- a/test/test_subagent_delivery_ttl_anchor.py +++ b/test/test_subagent_delivery_ttl_anchor.py @@ -607,7 +607,7 @@ async def test_an_auth_required_turn_settles_nothing(self, agent_root, tmp_path) ): assert await _start_next_queued_turn(state, slot) is True - slot._last_turn_auth_required = True + slot._queue_held = True done.set_result(None) await asyncio.sleep(0.05) diff --git a/test/test_subagent_scale.py b/test/test_subagent_scale.py index 3724d073324..4c5d3ec475e 100644 --- a/test/test_subagent_scale.py +++ b/test/test_subagent_scale.py @@ -1427,7 +1427,7 @@ async def test_an_auth_required_turn_is_not_a_confirmed_hand_off(self): slot._subagent_deliveries_inflight = 0 # Real attribute, not a MagicMock truthy stub: the stub below flips it # exactly as _run_chat does on a signed-out CLI. - slot._last_turn_auth_required = False + slot._queue_held = False orch.dashboard_state.get_slot = MagicMock(return_value=slot) mgr, on_done = self._capture_on_done(orch) ledger, settled = _wire_hold_settlement(orch, slot, mgr) @@ -1438,7 +1438,7 @@ async def _auth_required_run_chat(_state, _slot, _text, *, _on_consumed=None, ** # Exactly what _run_chat does on a signed-out CLI: record it and # return. No raise, no cancellation — and no consumption report, # because the model never saw the prompt. - _slot._last_turn_auth_required = True + _slot._queue_held = True marked: list[str] = [] with patch("kiro_crew.slack.gateway._run_chat", _auth_required_run_chat), \ @@ -1450,7 +1450,7 @@ async def _auth_required_run_chat(_state, _slot, _text, *, _on_consumed=None, ** await asyncio.sleep(0) await _settle(lambda: slot.task is None) - assert slot._last_turn_auth_required is True, ( + assert slot._queue_held is True, ( "precondition: the turn must have ended in the auth-required state" ) assert settled == [] and marked == [], ( diff --git a/website/capture/agent-switch-workspace-unavailable.html b/website/capture/agent-switch-workspace-unavailable.html new file mode 100644 index 00000000000..60ca5417b4b --- /dev/null +++ b/website/capture/agent-switch-workspace-unavailable.html @@ -0,0 +1,11 @@ + + + + + Agent-switch workspace-unavailable capture + + +
+ + + diff --git a/website/capture/agent-switch-workspace-unavailable.tsx b/website/capture/agent-switch-workspace-unavailable.tsx new file mode 100644 index 00000000000..c2c7af859b1 --- /dev/null +++ b/website/capture/agent-switch-workspace-unavailable.tsx @@ -0,0 +1,64 @@ +/* Evidence for the agent-switch workspace-unavailable notice. + * + * The 503 this change added answers a switch whose configured workspace root cannot be + * resolved. The keyboard cycles have no disabled state, so this toast is the only place that + * refusal is reported -- and it had no capture scene in the set this PR shipped. + * + * Mounts the REAL `AgentSwitchNotice` the app renders, with the copy resolved through the + * REAL `agentSwitchFailureMessage` from a REAL `ApiError`, so the frame cannot drift from + * either the markup or the mapping. + * + * ?theme=dark|light&case=workspace|turn + */ +import { createRoot } from 'react-dom/client' + +import AgentSwitchNotice from '../src/components/AgentSwitchNotice' +import { agentSwitchFailureMessage } from '../src/utils/agentSwitchFeedback' +import { ApiError } from '../src/api/client' +import { initI18n } from '../src/i18n/all' +import '../src/index.css' + +const params = new URLSearchParams(location.search) +const theme = params.get('theme') === 'light' ? 'light' : 'dark' +const which = params.get('case') === 'turn' ? 'turn' : 'workspace' +document.documentElement.setAttribute('data-theme', theme === 'light' ? 'kiro-light' : 'kiro-dark') + +initI18n('en') + +// Both refusals a switch can answer, so the frames show this one is NOT the turn-in-flight +// copy: the two arrive on the same surface and only the code tells them apart. +const ERRORS: Record = { + workspace: new ApiError( + 503, + 'the configured workspace directory is unavailable', + JSON.stringify({ + error: 'the configured workspace directory is unavailable', + code: 'workspace_unavailable', + }), + ), + turn: new ApiError( + 409, + 'conflict', + JSON.stringify({ error: 'a turn is in flight', code: 'turn_in_flight' }), + ), +} + +const WIRE: Record = { + workspace: 'POST /api/chat/slots/chat-1/agent -> 503 {"error":"...","code":"workspace_unavailable"}', + turn: 'POST /api/chat/slots/chat-1/agent -> 409 {"error":"...","code":"turn_in_flight"}', +} + +const message = agentSwitchFailureMessage(ERRORS[which]) + +function Scene() { + return ( +
+
{WIRE[which]}
+ {/* The toast is `position: fixed`, so it anchors to the viewport rather than to this + * wrapper -- the screenshot is taken of the page, not of the wrapper. */} + undefined} /> +
+ ) +} + +createRoot(document.getElementById('root')!).render() diff --git a/website/capture/clear-context-busy-refusal.html b/website/capture/clear-context-busy-refusal.html new file mode 100644 index 00000000000..0f34a327e04 --- /dev/null +++ b/website/capture/clear-context-busy-refusal.html @@ -0,0 +1,11 @@ + + + + + Clear-context busy refusal capture + + +
+ + + diff --git a/website/capture/clear-context-busy-refusal.tsx b/website/capture/clear-context-busy-refusal.tsx new file mode 100644 index 00000000000..6d524bc7aeb --- /dev/null +++ b/website/capture/clear-context-busy-refusal.tsx @@ -0,0 +1,260 @@ +/** + * Evidence for the clear-context busy refusal on a channel. + * + * Clearing a channel's context while a role's session had a turn in flight reported + * success and cleared nothing for that role: the server answers 200 with the refusing + * roles in `busy`, and no caller read that field. + * + * Mounts the REAL `Btn` and the REAL `ErrorNotice` the page renders, with the copy + * resolved through the REAL `clearContextBusyMessage` exported from `ChannelPage`, + * against the real stylesheet and live i18n catalog. The refusal surface shipped here + * is the in-page banner, so that is what these frames show -- an earlier revision of + * this scene mirrored a native `alert()`, which the page no longer raises. + * + * ?theme=dark|light&scope=all|agent|clean|total|failure + */ +import { useState } from 'react' +import { createRoot } from 'react-dom/client' +import { RotateCcw } from 'lucide-react' + +import { clearContextBusyMessage, clearContextBusyRefusal, AgentControlRow } from '../src/pages/ChannelPage' +import type { ChannelAgent } from '../src/pages/ChannelPage' +import ErrorNotice from '../src/components/ErrorNotice' +import { Btn } from '../src/components/ui' +import { ApiError } from '../src/api/client' +import { initI18n } from '../src/i18n/all' +import { i18nT } from '../src/i18n/t' +import '../src/index.css' + +/** Response shapes `api_channel_clear_context` can actually answer AFTER this change. + * A partial refusal is 200 with `busy`; a TOTAL refusal is a 409 that arrives as a throw, + * so it is exercised through the catch rather than this map. */ +const RESPONSES: Record = { + // Clear-all with two of three roles mid-turn -- partial, so 200 with `busy`. + all: { cleared: ['Scribe'], busy: ['Researcher', 'Analyst'] }, + // Contrast: nothing refused, so no banner is owed at all. + clean: { cleared: ['Researcher', 'Analyst', 'Scribe'], busy: [] }, +} + +/** The two THROWN paths, both rendered by the real `clearContextBusyRefusal`. The 409 + * must read as the localized refusal and NOT as the backend's English prose; anything + * else falls back to the generic copy. */ +const THROWN: Record = { + // `scope=agent` processes ONLY the addressed member, so `cleared` and `busy` hold at most + // one name between them and a refusal is always `busy && !cleared` -- a 409, never a 200. + agent: new ApiError( + 409, + 'conflict', + JSON.stringify({ + error: + 'context not cleared: Researcher had a turn in flight. Nothing was cleared — retry when idle.', + code: 'turn_in_flight', + busy: ['Researcher'], + }), + ), + total: new ApiError( + 409, + 'conflict', + JSON.stringify({ + error: + 'context not cleared: Researcher, Analyst had a turn in flight. Nothing was cleared — retry when idle.', + code: 'turn_in_flight', + busy: ['Researcher', 'Analyst'], + }), + ), + failure: new ApiError(500, 'channel store unavailable', ''), +} + +const params = new URLSearchParams(location.search) +const theme = params.get('theme') === 'light' ? 'light' : 'dark' +const scope = params.get('scope') || 'all' +document.documentElement.setAttribute('data-theme', theme === 'light' ? 'kiro-light' : 'kiro-dark') + +initI18n('en') + +// The clear-context dialogs are NATIVE `confirm()` calls, so no screenshot can show their +// chrome. This raises the real ones, so the harness reads the copy off the dialog event. +const CONFIRM_KEYS: [string, Record][] = [ + ['pages.channelPage.this_will_reset_conversation_history_for_all_age', {}], + ['pages.channelPage.reset_role_s_llm_session_the_channel_s_shared_me', { role: 'Researcher' }], +] + +const thrown = THROWN[scope] +const response = RESPONSES[scope] || RESPONSES.all +const label = + scope === 'agent' + ? i18nT('pages.channelPage.clear_context') + : i18nT('pages.channelPage.clear_context_2') + +const HEADERS: Record = { + agent: 'per-agent control, @Researcher mid-turn: ', + clean: 'clear-all, every role idle: ', + total: 'clear-all, EVERY role mid-turn: ', + failure: 'clear-all, channel store down: ', + retrying: 'partial refusal, retry in flight: ', + confirms: 'the three native confirm() bodies this change rewrote or added: ', + all: 'clear-all, @Researcher and @Analyst mid-turn: ', +} + +const WIRE: Record = { + total: 'POST /api/channels/ch-ops/clear-context -> 409 {"error":"...","code":"turn_in_flight","busy":["Researcher","Analyst"]}', + failure: 'POST /api/channels/ch-ops/clear-context -> 500 channel store unavailable', + retrying: 'POST /api/channels/ch-ops/clear-context -> in flight; the retry is disabled until it answers', + confirms: 'no request yet -- each line is the message the real confirm() was given', +} + +function Scene() { + const [notice, setNotice] = useState<{ title: string; message: string; warn?: boolean } | null>( + null, + ) + const [done, setDone] = useState(false) + const [asked, setAsked] = useState([]) + + // The page's composed path: a PARTIAL refusal answers 200 and never throws, a TOTAL one + // answers 409 and is localized by the real helper, and anything else is a generic failure. + const failTitle = i18nT('pages.channelPage.failed_to_clear_context') + const onClick = () => { + if (scope === 'confirms') { + // The REAL browser primitive the page calls, so the harness reads the shipped copy off + // the dialog rather than off a string this scene retyped. + setAsked( + CONFIRM_KEYS.map(([key, vars]) => { + const text = i18nT(key, vars) + confirm(text) + return text + }), + ) + return + } + if (thrown !== undefined) { + const busy = clearContextBusyRefusal(thrown) + // Mirrors `failClearContext`: a 409 is a WITHHELD clear, so it leads with the + // not-cleared title in warn chrome rather than claiming an outright failure. + setNotice({ + title: busy ? i18nT('pages.channelPage.clear_context_not_cleared_yet') : failTitle, + message: busy || (thrown instanceof Error ? thrown.message : failTitle), + warn: Boolean(busy), + }) + return + } + const busy = clearContextBusyMessage(response) + // Mirrors `noteClearRefusal`: a partial clear leads with the PARTIAL title, because a + // bold "Failed" over a body ending "Cleared for Scribe" contradicts itself. + const cleared = (response as { cleared?: unknown } | undefined)?.cleared + const partial = Array.isArray(cleared) && cleared.length > 0 + // Mirrors the page: a clean clear is ACKNOWLEDGED rather than answered with nothing, + // so silence here would evidence a surface the app no longer renders. + setDone(!busy) + setNotice( + busy + ? { + title: partial + ? i18nT('pages.channelPage.clear_context_partially_cleared') + : failTitle, + message: busy, + // Mirrors the page here too: without it these frames show a partial clear in + // danger chrome the app never renders, and the evidence contradicts the ship. + warn: partial, + } + : null, + ) + } + + if (scope === 'rowmarks') { + // The real row, not a copy: these three marks are the ONLY signal a reader watching the + // agents panel gets, and a hand-rolled stand-in would evidence the stand-in. + const member: ChannelAgent = { + id: 'a1', + role: 'Researcher', + agentName: 'researcher', + state: 'working', + listenMode: 'all', + approvalPolicy: 'writes', + } + const noop = () => undefined + return ( +
+
+ in-row states — pending (disabled, aria-busy), cleared, kept +
+
+
+ new Promise(() => undefined)} + /> +
+
+ +
+
+ +
+
+
+ ) + } + + return ( +
+
+ {HEADERS[scope] || HEADERS.all} + {WIRE[scope] || + `POST /api/channels/ch-ops/clear-context -> 200 ${JSON.stringify(response)}`} +
+ + + {label} + + + {/* NO hand-off, as the page has it: this notice can sit above an unsent composer + * draft, and the hand-off would unmount the page and destroy it. */} +
+ {done && ( +

+ {i18nT('pages.channelPage.clear_context_done')} + {scope === 'clean' && ` ${i18nT('pages.channelPage.clear_context_messages_deleted')}`} +

+ )} + {asked.length > 0 && ( +
    + {asked.map(text => ( +
  1. {text}
  2. + ))} +
+ )} + setNotice(null)} + testId="clear-context-error" + /> +
+
+ ) +} + +createRoot(document.getElementById('root')!).render() diff --git a/website/scripts/capture-agent-switch-notice.mjs b/website/scripts/capture-agent-switch-notice.mjs new file mode 100644 index 00000000000..6ad258cb28a --- /dev/null +++ b/website/scripts/capture-agent-switch-notice.mjs @@ -0,0 +1,79 @@ +/* Screenshots for the agent-switch refusal notice. + * + * Drives website/capture/agent-switch-workspace-unavailable.html, which mounts the REAL + * `AgentSwitchNotice` with copy resolved through the REAL `agentSwitchFailureMessage`. + * + * Each frame asserts the notice's own text before writing, so a frame is never committed as + * evidence of copy it does not show. The two cases share one surface and are told apart only + * by the error code, so each asserts the other's copy is ABSENT. + * + * 01-workspace-unavailable the 503 this change added + * 02-turn-in-flight the sibling 409, so the frames prove which copy is which + * + * Usage: + * npx vite --host 127.0.0.1 --port 6841 --strictPort # in another shell + * node scripts/capture-agent-switch-notice.mjs http://127.0.0.1:6841 ../temp-screenshots/agent-switch-notice + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' + +const BASE = process.argv[2] || 'http://127.0.0.1:6841' +const OUT = process.argv[3] || '../temp-screenshots/agent-switch-notice' +mkdirSync(OUT, { recursive: true }) + +let failed = false +function check(name, ok, detail) { + console.log(`${name}: ${ok ? 'OK' : 'MISMATCH'} ${detail}`) + if (!ok) failed = true + return ok +} + +const SCENES = [ + { + file: '01-workspace-unavailable', + case: 'workspace', + mustCarry: ["project folder isn't available", 'check that it exists'], + mustNotCarry: ['turn is in flight', 'the configured workspace directory is unavailable'], + }, + { + file: '02-turn-in-flight', + case: 'turn', + mustCarry: ['turn'], + mustNotCarry: ["workspace folder isn't available"], + }, +] + +const browser = await chromium.launch() + +for (const theme of ['dark', 'light']) { + for (const scene of SCENES) { + const page = await browser.newPage({ viewport: { width: 760, height: 150 }, deviceScaleFactor: 2 }) + await page.goto( + `${BASE}/capture/agent-switch-workspace-unavailable.html?theme=${theme}&case=${scene.case}`, + ) + await page.waitForSelector('[data-capture-root]') + const notice = page.getByTestId('agent-switch-notice') + await notice.waitFor({ timeout: 5000 }).catch(() => {}) + const shown = (await notice.count()) === 1 + const text = shown ? (await notice.textContent()) || '' : '' + const carried = scene.mustCarry.every(s => text.includes(s)) + // The backend's English prose must not reach this surface: that is the whole reason the + // helper maps this code instead of letting the generic path prefer the API message. + const leaked = scene.mustNotCarry.filter(s => text.includes(s)) + + const name = `${scene.file}-${theme}` + if ( + check( + name, + shown && carried && leaked.length === 0, + `shown=${shown} carried=${carried} leaked=${JSON.stringify(leaked)} text=${JSON.stringify(text.slice(0, 120))}`, + ) + ) { + await page.screenshot({ path: `${OUT}/${name}.png` }) + } + await page.close() + } +} + +await browser.close() +process.exit(failed ? 1 : 0) diff --git a/website/scripts/capture-clear-context-busy-refusal.mjs b/website/scripts/capture-clear-context-busy-refusal.mjs new file mode 100644 index 00000000000..1bb7d2d01e8 --- /dev/null +++ b/website/scripts/capture-clear-context-busy-refusal.mjs @@ -0,0 +1,188 @@ +/** + * Screenshots for the clear-context busy refusal on a channel. + * + * Drives the isolated capture entry (website/capture/clear-context-busy-refusal.html), + * which mounts the REAL `Btn` and the REAL `ErrorNotice` the page renders, with the copy + * resolved through the REAL `clearContextBusyMessage` exported from ChannelPage. + * + * The shipped refusal surface is the IN-PAGE banner, so each frame asserts the banner's + * own text before writing: it must name every refusing role, the cause, and the retry. + * A frame is not written unless those assertions hold, so an empty or mis-copied banner + * fails the run rather than being committed as evidence. Dialogs are expected PER SCENE: + * absent everywhere except the confirm scene, whose copy is read off the dialog event. + * + * 01-clear-all-two-roles-busy partial clear-all, two of three roles mid-turn + * 02-per-agent-role-busy the per-agent control, its addressed role refusing + * 03-clean-acknowledged contrast: nothing refused, the clear is acknowledged + * 07-confirm-dialog-copy the native confirm bodies, read off the dialogs + * + * Usage: + * npx vite --host 127.0.0.1 --port 6841 --strictPort # in another shell + * node scripts/capture-clear-context-busy-refusal.mjs http://127.0.0.1:6841 ../temp-screenshots/clear-context-busy-refusal + */ +import { chromium } from 'playwright' +import { mkdirSync } from 'node:fs' + +const BASE = process.argv[2] || 'http://127.0.0.1:6841' +const OUT = process.argv[3] || '../temp-screenshots/clear-context-busy-refusal' +mkdirSync(OUT, { recursive: true }) + +const browser = await chromium.launch() +let failed = false + +function check(name, ok, detail) { + console.log(`${name}: ${ok ? 'OK' : 'MISMATCH'} ${detail}`) + if (!ok) failed = true + return ok +} + +/** Scenes, with what the banner must carry for the frame to be honest. The copy reuses + * the page's own busy word ("working") rather than introducing separate vocabulary, and + * never makes the role LIST the subject of a verb, so two roles read as well as one. */ +const SCENES = [ + { + file: '01-clear-all-two-roles-busy', + scope: 'all', + banner: true, + mustCarry: ['Researcher', 'Analyst', 'kept for', 'still working', 'Try again when they finish', 'Cleared for Scribe', 'Context partially cleared'], + // The hand-off would unmount the page and destroy an unsent composer draft. The bold + // FAILURE lead is wrong here too: this scene did clear a role. + mustNotCarry: ['Ask the agent', 'Failed to clear context'], + }, + { + file: '02-per-agent-role-busy', + scope: 'agent', + banner: true, + // `scope=agent` touches only the addressed member, so a refusal there is `busy && !cleared` + // -- it takes the total-refusal lead, never the partial one. Both leads are non-failure + // and both render amber: the clear was withheld, so red stays for a real error. + mustCarry: ['Researcher', 'kept for', 'still working', 'Try again when they finish', 'Context not cleared'], + mustNotCarry: ['Ask the agent', 'Context partially cleared', 'Cleared for Scribe', 'Failed to clear context'], + }, + { + // A clean clear is acknowledged, so this frame evidences the acknowledgment rather + // than the blank surface the page stopped rendering. + file: '03-clean-acknowledged', + scope: 'clean', + banner: false, + mustCarry: ['Context cleared', 'deleted too'], + mustNotCarry: ['Try again', 'Context not cleared', 'still working'], + }, + { + // A TOTAL refusal is a 409 and arrives as a throw. It must render the SAME localized + // refusal as the partial case -- not the backend's English prose, which would read as + // doubled phrasing and land untranslated on a localized page. + file: '04-total-refusal-409', + scope: 'total', + banner: true, + mustCarry: ['Researcher', 'Analyst', 'kept for', 'still working', 'Try again when they finish'], + mustNotCarry: ['Nothing was cleared', 'turn in flight', 'Ask the agent'], + }, + { + // The generic failure path, which this change moved off `alert()`. + file: '05-generic-failure-inline', + scope: 'failure', + banner: true, + mustCarry: ['channel store unavailable'], + mustNotCarry: ['Context not cleared', 'Context partially cleared'], + }, + { + // The clear-context confirm bodies. They are NATIVE dialogs, so their chrome + // cannot be screenshotted; the harness instead reads each message off the dialog event + // and pins the copy, and the frame carries the text that was verified. + file: '07-confirm-dialog-copy', + scope: 'confirms', + banner: false, + mustCarry: [ + 'deletes the channel', + 'Configs are preserved', + "Researcher's context", + ], + mustNotCarry: ['Context not cleared', 'Context partially cleared'], + expectDialogs: [ + "This clears context for all agents and deletes the channel's messages. Configs are preserved. If any agent is still working, only the agents that are idle are cleared; a working agent's context and the channel's messages are kept.", + ], + }, + { + // The three in-row states, which no other scene mounts: the agents panel is where the + // user's pointer is, and the page-top banner is outside it. + file: '08-row-marks', + scope: 'rowmarks', + banner: false, + // The marks are icon-only, so their labels live in `title`/`aria-label` rather than in + // text; they are asserted from the DOM below instead of through `mustCarry`. + mustCarry: ['Researcher'], + mustNotCarry: ['Context not cleared', 'Context partially cleared'], + noClick: true, + rowMarks: true, + viewport: { width: 760, height: 320 }, + }, +] + +for (const theme of ['dark', 'light']) { + for (const scene of SCENES) { + const page = await browser.newPage({ viewport: scene.viewport || { width: 760, height: 250 }, deviceScaleFactor: 2 }) + + // The shipped path raises no dialog; assert that rather than assume it. + const dialogs = [] + page.on('dialog', async d => { + dialogs.push(d.message()) + await d.dismiss() + }) + + await page.goto(`${BASE}/capture/clear-context-busy-refusal.html?theme=${theme}&scope=${scene.scope}`) + await page.waitForSelector('[data-capture-root]') + if (!scene.noClick) await page.click('[data-capture-clear]') + // `attached`, not visible: the wrapper is empty on the clean scene, where the whole + // point is that no banner renders. + await page.waitForSelector('[data-capture-notice]', { state: 'attached' }) + + const notice = page.getByTestId('clear-context-error') + if (scene.banner) await notice.waitFor({ timeout: 5000 }).catch(() => {}) + const shown = (await notice.count()) === 1 + // Read the whole notice surface, not the banner alone: a clean clear renders an + // acknowledgment and no banner, so banner-only text cannot see it and a frame that + // showed nothing would assert clean. + const text = (await page.locator('[data-capture-notice]').textContent()) || '' + const carried = scene.mustCarry.every(s => text.includes(s)) + // The 409 frame's whole point is that the backend's English did NOT leak through. + const leaked = (scene.mustNotCarry || []).filter(s => text.includes(s)) + const bannerOk = shown === scene.banner + // Read from the DOM, not from the props this fixture passed: a mark the component failed + // to render would otherwise be "evidenced" by a frame that cannot show its absence. + let marksOk = true + if (scene.rowMarks) { + // Clicked HERE so the pending row is genuinely mid-request: its handler never resolves, + // which is the state the frame has to show and no other scene reaches. + await page.click('[data-capture-row="pending"] button[title="Clear context"]') + const pendingBtn = page.locator('[data-capture-row="pending"] button[aria-busy="true"]') + await pendingBtn.waitFor({ timeout: 5000 }).catch(() => {}) + marksOk = + (await pendingBtn.count()) === 1 && + (await pendingBtn.isDisabled()) && + (await page.locator('[data-capture-row="cleared"] [data-testid="agent-clear-done"]').count()) === 1 && + (await page.locator('[data-capture-row="kept"] [data-testid="agent-clear-kept"]').count()) === 1 && + (await page.locator('[data-capture-row="cleared"] [data-testid="agent-clear-kept"]').count()) === 0 + } + // Per scene, because the confirm scene's whole purpose is to RAISE dialogs: every + // expected message must have been seen, and no scene may raise an unexpected one. + const expected = scene.expectDialogs || [] + const dialogsOk = expected.length + ? expected.every(m => dialogs.includes(m)) && dialogs.length >= expected.length + : dialogs.length === 0 + const name = `${scene.file}-${theme}` + if ( + check( + name, + bannerOk && carried && leaked.length === 0 && dialogsOk && marksOk, + `banner=${shown}/${scene.banner} carried=${carried} leaked=${JSON.stringify(leaked)} marks=${marksOk} dialogs=${JSON.stringify(dialogs)} text=${JSON.stringify(text.slice(0, 140))}`, + ) + ) { + await page.screenshot({ path: `${OUT}/${name}.png` }) + } + await page.close() + } +} + +await browser.close() +process.exit(failed ? 1 : 0) diff --git a/website/src/App.tsx b/website/src/App.tsx index 38a8d37e35e..1f712508e8a 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -53,6 +53,7 @@ import { Rocket, Bell, Code, RefreshCw, Package, Loader2, Download, Hammer, XCir import { GithubIcon, DiscordIcon } from './components/BrandIcon' import { Toggle } from './components/ui' import OnboardingFlow from './components/OnboardingFlow' +import AgentSwitchNotice from './components/AgentSwitchNotice' import AgentImportFlow from './components/AgentImportFlow' import ErrorNotice from './components/ErrorNotice' import PrivacyChapter from './components/PrivacyChapter' @@ -3809,10 +3810,10 @@ export default function App() { {agentSwitchNotice && ( -
- {agentSwitchNotice.message} - -
+ dispatch(setAgentSwitchNotice(null))} + /> )} {/* Report a Problem — mounted by the nav rail's "Report issue" link. */} diff --git a/website/src/components/AgentSwitchNotice.tsx b/website/src/components/AgentSwitchNotice.tsx new file mode 100644 index 00000000000..94d63044789 --- /dev/null +++ b/website/src/components/AgentSwitchNotice.tsx @@ -0,0 +1,52 @@ +import ErrorNotice from './ErrorNotice' +import { agentSwitchOffersHandoff } from '../utils/agentSwitchFeedback' + +/** + * The agent-switch result toast. + * + * Why this surface changed AT ALL, rather than for consistency: this change adds a refusal that + * did not exist before -- a switch can now answer `503 workspace_unavailable` when the configured + * root is unreadable -- and this component is its ONLY renderer (mounted once, in `App`). That + * refusal is one the user cannot act on: no retry, no field to correct, the directory is simply + * not there. The shared `ErrorNotice` is the one place that recovers the structured context an + * error carries (route, endpoint, status, backend `code`) and offers it to the agent, so it is + * what keeps the new refusal from being a dead end. Bespoke markup rendered the sentence and + * discarded the `code` this change introduced, which is the part a recovery needs. + * + * This wrapper owns only what a FLOATING notice needs and the shared component cannot know -- + * viewport anchoring, elevation, and an OPAQUE backdrop, since the notice's own tint is + * translucent and would otherwise read against whatever it covers. + * + * `warn`, not the danger default: both refusals this reports -- a turn in flight, and an + * unavailable workspace root -- WITHHELD the switch without anything breaking. The hand-off is on + * because this surface has nothing to lose: it owns no editable field, and the composer draft the + * navigation passes through is flushed to its per-slot store on unmount. + */ +export default function AgentSwitchNotice({ + message, + onDismiss, +}: { + /** Resolved by `agentSwitchFailureMessage`; falsy renders nothing. */ + message?: string | null + onDismiss: () => void +}) { + if (!message) return null + return ( +
+ {/* No hand-off while a turn is IN FLIGHT: the hand-off creates and activates a new + * session, so it would move the user off the very turn this notice tells them to wait + * for -- the running turn is the state it protects, and that turn clears itself. An + * unavailable WORKSPACE has no such turn and does need the agent, so it keeps it. */} + +
+ ) +} diff --git a/website/src/components/ErrorNotice.tsx b/website/src/components/ErrorNotice.tsx index e7063860f58..2b2dd8c6156 100644 --- a/website/src/components/ErrorNotice.tsx +++ b/website/src/components/ErrorNotice.tsx @@ -1,5 +1,5 @@ import type { ComponentType, ReactNode } from 'react' -import { AlertTriangle, Sparkles, X } from 'lucide-react' +import { AlertTriangle, CircleAlert, Sparkles, X } from 'lucide-react' import AskAgentButton, { handoffErrorToAgent } from './AskAgentButton' import type { ErrorReport } from '../utils/errorReport' @@ -66,6 +66,13 @@ export function ErrorNoticeMenuItem({ * run of text that sits inside an existing button row — those sites are laid out * as flex children, so dropping a bordered box into one would break the row. The * variant is a layout choice only; both carry the same agent hand-off. + * + * ## `warn` is severity, and it is a separate axis from `variant` + * + * Danger chrome is read before the words are, so a notice reporting a PARTIAL + * SUCCESS in red alarm colours tells a scanning user the operation failed and + * invites them to re-run it. `warn` is for that case — something was withheld, + * nothing broke. Leave it off (the default) for a genuine failure. */ export default function ErrorNotice({ id, @@ -74,6 +81,7 @@ export default function ErrorNotice({ title, onDismiss, variant = 'block', + warn = false, askAgent = false, onHandoff, className = '', @@ -96,6 +104,11 @@ export default function ErrorNotice({ onDismiss?: () => void /** `block` = boxed banner; `inline` = compact text for an existing flex row. */ variant?: 'block' | 'inline' + /** + * Severity, independent of `variant`. On for an outcome that withheld + * something without failing (a partial clear); off for a real failure. + */ + warn?: boolean /** * Opt IN to the agent hand-off. **Defaults to `false`, and the direction of that * default is the safety property.** @@ -139,15 +152,24 @@ export default function ErrorNotice({ }) { if (!message) return null + const fg = warn ? 'text-warn' : 'text-danger' + // Politeness rides the SEVERITY axis rather than a prop: a withheld action broke nothing, + // so interrupting speech misreports it -- and per-site roles drifted apart once already. + const role = warn ? 'status' : 'alert' + const dismissFg = warn ? 'text-warn/70 hover:text-warn' : 'text-danger/70 hover:text-danger' + // Warn ranks BELOW danger: a withheld act lost nothing, so it must not out-shout a real + // failure. Circle < triangle in weight, and danger's triangle is left as every surface has it. + const Icon = warn ? CircleAlert : AlertTriangle + if (variant === 'inline') { return ( -