diff --git a/.github/black-baseline.txt b/.github/black-baseline.txt index 343f9a09cbc..b93b1de5a31 100644 --- a/.github/black-baseline.txt +++ b/.github/black-baseline.txt @@ -360,7 +360,6 @@ test/test_chat_nav.py test/test_chat_slot_create_folder.py test/test_chat_slot_end_wait.py test/test_chat_slot_interrupt.py -test/test_chat_slot_reasoning_effort.py test/test_chat_title.py test/test_cli_desktop.py test/test_cli_lazy_imports.py diff --git a/docs/reference/kiro-cli/README.md b/docs/reference/kiro-cli/README.md index 50815e4b5d7..478659a852c 100644 --- a/docs/reference/kiro-cli/README.md +++ b/docs/reference/kiro-cli/README.md @@ -13,6 +13,38 @@ a supported range, and a 3.x CLI satisfies all of them: `mcp_hot_reload.py`'s `kiro_cli.py`'s `SPEC_PERMISSIONS_MIN_VERSION` (2.23.0, the agent-spec `permissions` block) and `MANDATORY_MCPS_DROP_MIN_VERSION` (2.27.0, deferring Crew's MCP servers under Tool Search). +None of these floors is effort-related: the `kirocrew.effortOwned` ownership +record in the workspace `cli.json` counts only while its sibling +`kirocrew.effortOwnedStamp` equals the file's whole-second mtime, so it depends +on kiro-cli tolerating two keys it does not know when it loads workspace +settings, verified on kiro-cli 2.27.1. The one tripwire is +`test/test_kiro_cli_effort_ownership_pin.py`, which fails when the BUNDLED pin in +`packaging/kiro-cli-version` is raised past that verified version; a `kiro-cli` +the host installed itself is not covered by it. After a kiro-cli bump, re-verify two things +on the new version. First, kiro-cli still loads a workspace `cli.json` carrying +the `kirocrew.effortOwned` and `kirocrew.effortOwnedStamp` keys (`settings list` +shows the workspace `chat.modelDefaults`, and a chat spawns and answers), and +after a `kiro-cli settings --workspace` write the file's whole-second mtime no +longer equals the recorded stamp, so Kiro Crew's readers treat the record as void +instead of claiming the rewritten entries. Whether that write keeps or drops the +two keys does not matter; a write that left the stamp equal to the new mtime +would. Second, a live `/effort ` push still sets the +session's effort: kiro-cli's next `_kiro.dev/metadata` notification reports that +level, under `reasoning.effort` on kiro-cli 2.27.1. That push is how a chat keeps +its own level over a shared overlay that holds another one, and kiro-cli 2.27.1's +ACP command list marks `/effort` deprecated. On kiro-cli 2.34.0 the next +`_kiro.dev/metadata` after `/effort high` and after `/effort medium` reported that +level, and neither those pushes, a `session/set_model` away and back, nor the +session's start and close changed a byte or an mtime under the work directory's +`.kiro` or in the user-level `cli.json`. kiro-cli 2.34.0 answers a command name +it does not know with a JSON-RPC error (`-32700`), which makes the post-spawn push +raise and `_reassert_projected_effort` log a warning, so a build that removes +`/effort` is not silent. It answers a level it refuses with `success: false` and +no error, which no `/effort` sender reports yet (#23981). If the push does not +land, move every +`/effort` sender to kiro-cli's replacement channel before the bump ships. Then raise +`EFFORT_OWNERSHIP_VERIFIED_KIRO_CLI` in that test and update the +verified-version note in `docs/system-specs/modules/providers.md`. So these pages describe what upstream published on the fetch date recorded per row, not a supported-version contract. diff --git a/docs/system-specs/modules/acp-client.md b/docs/system-specs/modules/acp-client.md index 0b470f6ad56..d1376d319ae 100644 --- a/docs/system-specs/modules/acp-client.md +++ b/docs/system-specs/modules/acp-client.md @@ -420,6 +420,16 @@ repository's exclude list. Only missing lines are appended, the user's own lines are never rewritten, and a failed exclude write never blocks the overlay. A repository that already tracks `.kiro/settings/cli.json` still sees it modified: git's exclude list does not hide tracked files (known limit). +The lock admits a `.kiro` or `settings` link component by component. It uses +`lstat` and `readlink` only on names already inside the resolved work directory, +then decides containment from the link text before probing the target. A link +naming a remote, outside, other-drive, or looping path is refused without being +opened or resolved. Projection and rollback then read and write the admitted +folder. A clear or Default under a refused link reports that it could not change +the file, whether or not the target contains `cli.json`. A component swapped for +a link after admission is still refused by the pinned walk (see +[providers](providers.md)); an outside link skips projection and logs the existing +refusal. Crew records the original local inheritance key's presence and value in `kirocrew.skillDiscovery.previousInheritance`. Rollback restores that snapshot only while the native key still equals Crew's asserted `true`, removes Crew's @@ -474,7 +484,7 @@ removes it, and its name is no sweep candidate. The doctor's census below still counts it with the unmarked folders, since counting deletes nothing. The key predicate and corresponding directory-name regex come from one shared shape table; stable `cron::` keys are not one-run directories. -`AcpProvider.start` writes a `.kirocrew-run-dir` marker before +`AcpProvider.start` (`providers/acp_lifecycle.py`) writes a `.kirocrew-run-dir` marker before any other writer: two ASCII lines, the data-home identity (`data_home_id()`, the digest of the resolved `config_dir()`) and the gateway pid. That marker is the directory's only provenance: a name under the workspace root is not one (a person diff --git a/docs/system-specs/modules/providers.md b/docs/system-specs/modules/providers.md index 112123782ed..b9cf38b8d69 100644 --- a/docs/system-specs/modules/providers.md +++ b/docs/system-specs/modules/providers.md @@ -704,10 +704,10 @@ the binding and the reach. | Provider owner | Owns | |---|---| -| `providers/acp_cli_overlay.py` | the workspace `cli.json` overlay a kiro-family spawn reads: the effort and Tool Search writers, the effort clear and the recovery read, and `_apply_effort_overlay` / `_apply_tool_search_overlay` | -| `providers/acp_model_effort.py` | the model and reasoning effort a session runs at: the picker's advertised-model revalidation, `set_model`, and the effort vocabulary, resolution, live push, rollback and clear | +| `providers/acp_cli_overlay.py` | the workspace `cli.json` overlay a kiro-family spawn reads: the effort and Tool Search writers, ownership-provenance read/publish helpers, guarded effort clear, spawn fence, and `_apply_effort_overlay` / `_apply_tool_search_overlay` | +| `providers/acp_model_effort.py` | the model and reasoning effort a session runs at: the picker's advertised-model revalidation, `set_model`, effort vocabulary and resolution, post-spawn reassertion, live push, rollback and clear | | `providers/acp_runtime_start.py` | the cold start of a runtime-backed session: spawn, resume past a stale native session lock and the fresh-session fallback, the configured model pin, the startup telemetry, the session identity handed to the runtime, and the start-time check for an MCP server that gave the session no tools | -| `providers/acp_lifecycle.py` | the start and shutdown sequence, the registry claim on a derived run work directory and its reclaim, and the native session-file cleanup | +| `providers/acp_lifecycle.py` | the start sequence that projects the overlay before spawn, shutdown, the registry claim on a derived run work directory and its reclaim, and the native session-file cleanup | | `providers/acp_turns.py` | turns on the inner client: prompt and slash-command streams and their event translation, manual compaction and the wait for its result, cancel and steer | `AcpProvider` keeps its constructor, its properties and class defaults, `approve_tool` @@ -744,12 +744,18 @@ in [agent-host-contract.md](agent-host-contract.md). - `is_alive()` → `AcpClient.is_responsive()` (600s stale threshold) - `is_process_alive()` → OS-level process check -**Reasoning effort** (Opus/Sonnet/Fable **and GPT-5.x** — shared vocabulary in `effort.py`: levels `low|medium|high|xhigh|max`, resolution via `resolve_effort_for_model` with priority slot-override > workspace default > None). Capability authority is per harness. For kiro and claude the registry answers (`model_supports_effort`). For members of `ACP_BACKENDS_EFFORT_FROM_ADVERTISED_OPTION` (pi, deepseek, goose, opencode) the session's advertised config option answers `supports_effort` (False when the advertised list contains only `off` — an off-only option signals no real thinking level; for every member `_sync_effort_levels` admits the advertised levels to the validation set through `register_reasoning_effort_values` and never replaces the global fallback order, which a slot whose provider advertises no effort option of its own, kiro-cli, is served; after a live `set_model` the option read is the one the `model` write's own `session/set_config_option` result carries, which `AcpClient.set_config_option` stores as `AcpSessionHandle.set_config_option` does, because the `config_option_update` that precedes it is only buffered by `_wait_for_response`) and the allowed levels: `_resolve_effort` passes `levels=` (the advertised list) and `normalize=` (`effort_config_option_value`, the backend's spelling of a level) to `resolve_effort_for_model`, so the fold runs before the advertised-list filter and before the write. A Crew level a member has no spelling for (deepseek's `medium` and `xhigh`) is hidden, not mapped: `AcpProvider.accepts_effort_level` answers False for it, the dashboard effort route refuses it with a 400 `effort_level_not_offered` before anything persists, `change_effort` raises on it, and `_set_effort_config_option` writes nothing for it and walks its step-down over advertised rungs only, so every value pushed to a member is one that session advertised. The registry capability is a conservative allowlist of known-capable families (`opus`/`sonnet`/`fable`/`gpt`, minus a hard `haiku` exclusion), verified against kiro-cli 2.12/2.13 over ACP — kiro rejects `/effort` on the other third-party models (deepseek/minimax/glm/qwen/auto) with "Effort configuration is currently not available on ". A new model family lands as unsupported until confirmed (safe default: the slider hides). On kiro-cli (`ACP_BACKENDS_EFFORT_OVERLAY`) it is applied via a workspace `cli.json` overlay at `/.kiro/settings/cli.json` → `chat.modelDefaults...effort`, written before every spawn (`_write_cli_overlay`) and read back on init (`_read_cli_overlay`) so the provider's map names the level kiro-cli will run. The overlay's scope is the work dir, so every session in one project shares one entry per model. KAS never opens it and takes the level as the `effortLevel` config option, so the file is neither written, read back nor cleared for it: read back there, it started a new chat at whatever level another chat in the same work dir picked last. A KAS slot keeps its pick across a gateway restart through its own transcript metadata, which the dashboard hands the factory as `reasoning_effort_override`, and across a compaction restart or reset successor through the session's recorded override, which the dashboard effort route replaces with the live pick (`SessionManager.record_effort_override`). A session the dashboard does not start (cron, Slack, webhook) resolves its level from config (crew pin, role, `agent.reasoning_effort`) on every cold start: a level picked live on such a session from a dashboard slot reaches its successors, but a cold start from the channel side reads no dashboard slot, for effort as for the model. The `` sub-object is **family-specific** (`effort_settings_key`): `output_config` for Claude models, `reasoning` for GPT models — kiro silently ignores the wrong key, so a mismatched shape would survive a live push but drop on respawn. `_write_cli_overlay` removes stale effort from the other family key while preserving unrelated settings; `_clear_cli_overlay_effort`/`_read_cli_overlay` sweep both keys. Every in-product writer of that file takes the shared `.kirocrew-cli-settings.lock` sidecar (see [acp-client](acp-client.md)), under one of TWO ceilings: a native launch takes the startup one (it cannot wait, and a launch that loses the lock falls back to authored agents), while an operator action takes a ceiling far above projection's own sub-second critical section. `_clear_cli_overlay_effort` is an operator action, so it runs through `asyncio.to_thread` — waiting that long on the loop would freeze every session — and losing the lock there means a stuck holder rather than routine contention. The WRITE side is the same shape and gets the same treatment: `change_effort` applies the overlay off-loop under the action ceiling and refuses to push live at all unless it persisted, rolling the per-model override back and raising, because the file is what a respawn reads — reporting a live change over a write that did not land promises a level the next reset undoes. The pre-spawn overlay writes keep the startup ceiling on the loop: a launch cannot wait, and a launch whose overlay is missing still pushes at spawn. That is what keeps its answer two-valued: a failure that happened by design would need a third state for "nothing changed", and one that means something is wrong does not. It returns whether the file stopped naming the model: an absent file, an absent entry and a malformed file are all successes (`_read_cli_overlay` reads a malformed file as `{}` too, so nothing re-seeds), while a lock failure and a read that fails on a file that EXISTS under the held lock leave the level on disk. `clear_effort` answers that case with a THIRD outcome (`None`), not a bool: both bool values make the handler commit the cleared slot value — the reset branch before its teardown, and the success path before its final 200 — so either would show "default" over an overlay that still holds the level, and `False` additionally resets into re-reading that same level. `None` means nothing changed on either side, and the handler answers it with a retryable 409 `effort_overlay_busy` that commits nothing and resets nothing. `change_effort` has no such case: it raises, because a change that did not persist is a failure rather than a no-op. Clearing to a resolvable workspace default runs under the same write-first discipline: it applies the overlay off-loop under the action ceiling and pushes `/effort` only once that landed, because a live push over a write that did not persist reports a default the next spawn replaces with the level just cleared — a failed write there restores the popped override and answers `None` as well. `clear_effort` must not report a clear on that answer — the level is still on disk and `_read_cli_overlay` re-seeds `_effort_per_model` at construction, so the session reset a clear asks for would re-apply it. It restores the popped entry and logs a warning naming the model, and returns True — not False: the handler resets the session on False, and that reset would re-read the same level, so the operator would pay a reset and still run the old effort. True here means "no reset needed", which is the true answer when nothing changed on either side; the warning is what asks for a retry. The `change_effort` rollback path restores the level the same way for the same reason, in both of its shapes: the one that clears an overlay its live push never applied, and the one that rewrites the overlay back to the prior level. When either write loses the lock the file keeps the attempted level and construction re-seeds from it, so the map follows the FILE and a warning names the level a respawn adopts — reporting an undo the next spawn contradicts would be worse than admitting the rollback is incomplete. Live change pushes `/effort` with the TuiCommand args form (`send_command(args={"level": …})`). The factory threads `reasoning_effort_override` → `effort_per_model[current_model]`, keyed by the configured spelling; on the claude client path `start` moves that entry to the model the session records after `ensure_ready` (claude records the advertised spelling, e.g. `claude-opus-5-5[1m]` for a `claude-opus-5.5` pin), and leaves it in place when every spelling was refused and the session runs the backend default. For an advertised-option harness the factory carries the level forward and leaves judgement to `_resolve_effort` once `session/new` has advertised the option, so a cold start does not drop it on the registry's answer. When nothing is pinned (the resolved model is empty, as with an explicitly empty `agent.model` or a pin that collapses to `auto`), the factory passes a valid level, or any level on an advertised-option harness, as `unbound_effort` instead of keying it, because no model id exists until the backend answers `session/new`. Every effort read and write keys on `_effort_model`: the pinned model, else the backend-reported `served_model` once known. `_bind_unbound_effort` runs on every (re)start, after the session is ready and before `_apply_initial_effort`. It stores the level under the served model, replacing any level already stored there: a live change moves the unbound level with it, so a stored level that differs can only be one a previous run left in the workspace overlay, which construction seeds the map from on kiro-cli, and keeping it would run the session at a level the caller did not ask for. On the kiro family, whose level otherwise rides the spawn-time overlay, it pushes the level live through `change_effort` whenever the overlay does not already hold it, which also writes the overlay for the next spawn. That covers an unpinned kiro-cli, which is launched without `--model` and serves its own `chat.defaultModel` when one is set. Before that call the map holds the level the session was spawned with (the overlay's, or none), so a push kiro-cli refuses rolls back to that level rather than leaving the requested one in the map and the overlay while the session runs another. kiro-cli reports `currentModelId` `auto` for an `auto` session, and `served_model` filters that to `""`, so such a session still offers no effort, as before. A level the serving model does not take, or one with no served model at all, is logged as not applied once per provider. `clear_effort` drops the unbound level so the next restart does not bring it back, and a successful `change_effort` replaces it. Otherwise, when a valid requested effort cannot be threaded on a cold start (the resolved model is not effort-capable) the factory's gate logs one warning naming the level, the session, and the resolved model — reporting its own drop decision on surfaces that construct a fresh provider, an explicit `reasoning_effort_override` always warns (a caller's own dropped request is the event the gate exists to surface), while a drop sourced only from the config default (`agent.reasoning_effort`) is deduped once per (model, level) for the factory's lifetime so one static configuration fact does not repeat on every construction. A `reasoning_effort_override` on a warm-pool claim is applied post-claim via `provider.change_effort` (updating `_effort_per_model` and the `cli.json` overlay write) rather than bypassing the pool, recovering pool-hit startup latency; if the claimed model does not support effort, `change_effort` returns False and a corresponding drop warning is logged. The dashboard handler routes through `change_effort`/`clear_effort` and only resets the session when there is no live provider. Non-effort-capable models persist the slot value without a live apply or reset. +**Reasoning effort** (Opus/Sonnet/Fable **and GPT-5.x** — shared vocabulary in `effort.py`: levels `low|medium|high|xhigh|max`, resolution via `resolve_effort_for_model` with priority slot-override > workspace default > None). Capability authority is per harness. For kiro and claude the registry answers (`model_supports_effort`). For members of `ACP_BACKENDS_EFFORT_FROM_ADVERTISED_OPTION` (pi, deepseek, goose, opencode) the session's advertised config option answers `supports_effort` (False when the advertised list contains only `off` — an off-only option signals no real thinking level; for every member `_sync_effort_levels` admits the advertised levels to the validation set through `register_reasoning_effort_values` and never replaces the global fallback order, which a slot whose provider advertises no effort option of its own, kiro-cli, is served; after a live `set_model` the option read is the one the `model` write's own `session/set_config_option` result carries, which `AcpClient.set_config_option` stores as `AcpSessionHandle.set_config_option` does, because the `config_option_update` that precedes it is only buffered by `_wait_for_response`) and the allowed levels: `_resolve_effort` passes `levels=` (the advertised list) and `normalize=` (`effort_config_option_value`, the backend's spelling of a level) to `resolve_effort_for_model`, so the fold runs before the advertised-list filter and before the write. A Crew level a member has no spelling for (deepseek's `medium` and `xhigh`) is hidden, not mapped: `AcpProvider.accepts_effort_level` answers False for it, the dashboard effort route refuses it with a 400 `effort_level_not_offered` before anything persists, `change_effort` raises on it, and `_set_effort_config_option` writes nothing for it and walks its step-down over advertised rungs only, so every value pushed to a member is one that session advertised. The registry capability is a conservative allowlist of known-capable families (`opus`/`sonnet`/`fable`/`gpt`, minus a hard `haiku` exclusion), verified against kiro-cli 2.12/2.13 over ACP — kiro rejects `/effort` on the other third-party models (deepseek/minimax/glm/qwen/auto) with "Effort configuration is currently not available on ". A new model family lands as unsupported until confirmed (safe default: the slider hides). Applied via a workspace `cli.json` overlay at `/.kiro/settings/cli.json` → `chat.modelDefaults...effort`, projected before every spawn by `_apply_effort_overlay`: the session's own resolved level is written (`_write_cli_overlay`) together with its exact-match provenance in the flat top-level `kirocrew.effortOwned` map; when none resolves, `_clear_cli_overlay_effort` removes the model's entry only if that in-file record names the exact active-key value still present. The record counts only while the sibling `kirocrew.effortOwnedStamp` equals the whole-second mtime of the `cli.json` the reader loaded (`effort_ownership_stamp_matches` in `workspace_cli_settings.py`). Every Kiro Crew writer of the file (the ACP effort writer, the skill projection's `carry_effort_ownership` carry-through, and Sage's review overlay) stamps a staged file that carries the record with an even second at least three seconds back before its rename, so the record and the inode that validates it appear together; a document with no record is published with no stamp and a natural mtime. A writer whose document equals the file it read under the lock, apart from the stamp, while that file's record is valid does not write (`owned_document_unchanged`): the file keeps its bytes and its mtime, so the record stays valid and a same-level projection or a Tool Search write before each spawn leaves a committed `cli.json` unmodified. Any rewrite outside those writers voids the record, kiro-cli's own `settings --workspace` writes included: a rewrite that keeps the unknown keys still gives the file a new mtime the stamp does not name, and one that drops them has no record at all. Either way every entry Kiro Crew wrote reads as the operator's: projections stop claiming those values, ownership-guarded clears stop removing them, and a Default or `auto` chat keeps those levels and runs them until someone removes them; that chat's pre-spawn warning names the level. Nothing is deleted. What kiro-cli has to tolerate is therefore two keys it does not know, verified on kiro-cli 2.27.1: `settings list` loads the workspace `chat.modelDefaults` and `toolSearch.*` settings with the `kirocrew.effortOwned` and `kirocrew.effortOwnedStamp` keys present, and a chat spawns and answers the same as in a work dir without it. Whether kiro-cli's own writes keep the key no longer decides anything. The re-check to run after each kiro-cli bump is in [docs/reference/kiro-cli/README.md](../../reference/kiro-cli/README.md), and `test/test_kiro_cli_effort_ownership_pin.py` fails when the bundled `packaging/kiro-cli-version` pin is raised past the verified version without it. A rewrite that keeps the record voids it only by moving the file's mtime, so one that also keeps the mtime is not seen. `cp -p`, `rsync -t` and a backup restore copy the mtime with the content, so a copy of a file Kiro Crew wrote keeps its record, which still describes that content. A hand edit keeps the record only when a tool such as `touch -r` sets the mtime back to the stamped second, and even then an entry whose level the edit changed no longer equals its record and reads as the operator's. Only an edit to the record itself makes Kiro Crew claim a level it did not write, and a Default or `auto` start then removes that level from the file, so it is lost rather than run in another chat. An operator-authored entry has no record, and an operator edit (any rewrite outside Kiro Crew's stamping writers) voids the record because the file's mtime no longer equals the stamp, so both survive a Default-session pre-spawn projection and start while stale ownership is dropped. The `auto` branch applies that ownership check to every recorded model. A lock-free `cli.json` existence check comes first, so a directory with no overlay is not given an overlay or lock. Every session in the work dir shares `cli.json`, so it is never read back into `_effort_per_model`: a level in it may belong to another session or the operator, and adopting it would run that level in a session whose slot shows Default. What a session runs comes only from what the factory threads — the slot's persisted level (restored from its saved metadata after a restart), a spawn's request, and configured defaults. The `` sub-object is family-specific (`effort_settings_key`): `output_config` for Claude models and `reasoning` for GPT models. Kiro ignores the wrong key. `_write_cli_overlay` changes only the active key during an automatic projection and preserves an `effort` under the other family key. Ownership-guarded clears use the model's active family key, and explicit clears sweep both keys. The ACP writer is the in-product writer of the ownership record for chat sessions (Sage's review overlay writes its own review entries under the same stamp rule, and the skill projection only carries an existing record through). It takes the shared `.kirocrew-cli-settings.lock` sidecar (see [acp-client](acp-client.md)) and publishes an active-key effort value plus provenance in one atomic `cli.json` write. Ownership is record membership: Kiro Crew owns an active-key effort value only when its `kirocrew.effortOwned` record names that exact non-empty level and the file's `kirocrew.effortOwnedStamp` still equals its mtime. Every other present value (a level the operator set, a JSON `null`, a non-string) is the operator's. An automatic projection keeps it and drops only a stale or malformed record; an ownership-guarded clear leaves it; an unowned value equal to the requested level is kept and never claimed. When no record is stale or malformed the writer leaves the file unchanged. `_clear_cli_overlay_effort` takes the action ceiling by default: an operator action runs it through `asyncio.to_thread` — waiting that long on the loop would freeze every session — and losing the lock there means a stuck holder rather than routine contention. The pre-spawn projection passes the startup ceiling instead, like the pre-spawn write. The WRITE side takes the same action ceiling. `change_effort` applies the overlay off-loop and refuses to push live unless it persisted or it kept an operator-authored value. In the kept-value case the live `/effort` push carries the session's resolved level, and a later fresh session receives the same level through reassertion. `_write_cli_overlay` therefore never returns silently over an existing overlay it could not merge into. This effort writer leaves a `cli.json` that cannot be read (`OSError`) or cannot be parsed into a settings object byte-for-byte unchanged and raises, so the projection reports failure instead of a level the file does not hold. `_write_tool_search_overlay` merges through the same read (`_read_cli_overlay_document`) and refuses the same way, so neither writer ever resets a `cli.json` it could not parse; `_apply_tool_search_overlay` logs the refused write and the session still starts. The pre-spawn overlay writes keep the startup ceiling, and `start()` runs the projection through `asyncio.to_thread` so that ceiling is a real wait: on the loop thread the lock is single-shot, so a session starting while another chat's `change_effort` held the lock across its read and write would, on the loop, give up at once and spawn into the level that chat left. Cancellation of `start()` is delayed until that worker completes, so an abandoned start cannot write after its replacement has projected. A launch can pay a two-second wait, and a launch whose overlay is missing still pushes at spawn. Construction does no effort file I/O. `start()` runs the projection through `asyncio.to_thread` before every spawn. Its answer stays two-valued: a failure that happened by design would need a third state for "nothing changed", and one that means something is wrong does not. Its successful return means the ownership decision completed: a matching Kiro Crew value was removed, or an unowned/operator-edited value was preserved and any stale record was dropped. The explicit `clear_effort` UI path passes `owned_only=False`, because the operator has directly asked to remove this model's entry. When a workspace default resolves, explicit Default removes both family-shape effort entries and projects the workspace default in one locked atomic write, so the old entry is never published absent the replacement; a non-object `chat.modelDefaults`, or a non-object entry or effort object for that model, refuses the rewrite and leaves `cli.json` byte-for-byte unchanged rather than discarding operator data. Automatic clears retain the ownership guard, keep an operator-authored entry, and push the resolved workspace default live. An ownership-guarded removal emits one bounded, escaped info line naming the changed `cli.json` file and removed `model=level` pairs; if a level was set by hand to the same value, set it again after the removal. An absent file or entry and a `cli.json` that cannot be parsed into a settings object are successful no-ops that leave the file's bytes unchanged; a malformed ownership map owns nothing, while a lock failure and a read that fails on a file that exists under the held lock return failure. `clear_effort` answers that case with a THIRD outcome (`None`), not a bool: both bool values make the handler commit the cleared slot value — the reset branch before its teardown, and the success path before its final 200 — so either would show "default" over an overlay that still holds the level, and `False` additionally resets into re-reading that same level. `None` means nothing changed on either side, and the handler answers it with a 409 `effort_overlay_busy` that commits nothing and resets nothing. Its message lists the causes: a held lock, which a retry outlasts, or a file that is a link or in a folder that leaves the work dir or sits on a sensitive path, unreadable, not a JSON object, holding a non-object setting for that model, or past the 1 MiB read ceiling or would be once rewritten, which must be fixed first. `change_effort` has no such case: it raises, because a change that did not persist is a failure rather than a no-op. Clearing to a resolvable workspace default runs under the same write-first discipline: it applies the overlay off-loop under the action ceiling and pushes `/effort` only once that landed, because a live push over a write that did not persist reports a default the next spawn replaces with the level just cleared — a failed write there restores the popped override and answers `None` as well. `clear_effort` must not report a clear on that answer — the level is still on disk and the respawn a reset asks for would meet the same held lock, so it would spawn into that level. It restores the popped entry and logs a warning naming the model, and returns True — not False: the handler resets the session on False, and that reset would re-read the same level, so the operator would pay a reset and still run the old effort. True here means "no reset needed", which is the true answer when nothing changed on either side; the warning is what asks for a retry. The `change_effort` rollback path restores the level the same way for the same reason, in both of its shapes: the one that clears an overlay its live push never applied, and the one that rewrites the overlay back to the prior level. When either write loses the lock the file keeps the attempted level — the level the session was asked to run — so the map follows the FILE, the next spawn projects it back, and a warning names the level a respawn adopts: reporting an undo the file does not show would be worse than admitting the rollback is incomplete. Live change pushes `/effort` with the TuiCommand args form (`send_command(args={"level": …})`). The factory threads `reasoning_effort_override` → `effort_per_model[current_model]`, keyed by the configured spelling; on the claude client path `start` moves that entry to the model the session records after `ensure_ready` (claude records the advertised spelling, e.g. `claude-opus-5-5[1m]` for a `claude-opus-5.5` pin), and leaves it in place when every spelling was refused and the session runs the backend default. For an advertised-option harness the factory carries the level forward and leaves judgement to `_resolve_effort` once `session/new` has advertised the option, so a cold start does not drop it on the registry's answer. When nothing is pinned (the resolved model is empty, as with an explicitly empty `agent.model` or a pin that collapses to `auto`), the factory passes a valid level, or any level on an advertised-option harness, as `unbound_effort` instead of keying it, because no model id exists until the backend answers `session/new`. Every effort read and write once the session is ready keys on `_effort_model`, the post-spawn push included: the pinned model, else the backend-reported `served_model` once known. The projection before a spawn and its fence key on `_spawn_effort_model` instead, the pin, else `auto`: kiro-cli picks its model after it reads the file, and the model the last session served need not be the one the next serves. `_bind_unbound_effort` runs on every (re)start and every fresh warm-runtime conversation, after the session is ready and before `_reassert_projected_effort` (and, on a start, `_apply_initial_effort`), because a fresh session can serve another default than the last. It stores the level under the served model, replacing any level already stored there: a live change moves the unbound level with it, so a stored level that differs did not come from this caller, and keeping it would run the session at a level the caller did not ask for. On the kiro family it pushes a level not yet stored for that model live through `change_effort`, which also writes the overlay; a later start projects as `auto`, finds the level stored, and the post-spawn push carries it. That covers an unpinned kiro-cli, which is launched without `--model` and serves its own `chat.defaultModel` when one is set. Before that call the map holds what it held when the session started (nothing on a first start), so a push kiro-cli refuses rolls back to that rather than leaving the requested level in the map and the overlay while the session runs another. kiro-cli reports `currentModelId` `auto` for an `auto` session, and `served_model` filters that to `""`, so such a session still offers no effort, as before. A level the serving model does not take, or one with no served model at all, is logged as not applied once per provider. `clear_effort` drops the unbound level so the next restart does not bring it back, and a successful `change_effort` replaces it. Otherwise, when a valid requested effort cannot be threaded on a cold start (the resolved model is not effort-capable) the factory's gate logs one warning naming the level, the session, and the resolved model — reporting its own drop decision on surfaces that construct a fresh provider, an explicit `reasoning_effort_override` always warns (a caller's own dropped request is the event the gate exists to surface), while a drop sourced only from the config default (`agent.reasoning_effort`) is deduped once per (model, level) for the factory's lifetime so one static configuration fact does not repeat on every construction. A `reasoning_effort_override` on a warm-pool claim is applied post-claim via `provider.change_effort` (updating `_effort_per_model` and the `cli.json` overlay write) rather than bypassing the pool, recovering pool-hit startup latency; if the claimed model does not support effort, `change_effort` returns False and a corresponding drop warning is logged. The dashboard handler routes through `change_effort`/`clear_effort` and only resets the session when there is no live provider. Non-effort-capable models persist the slot value without a live apply or reset. + +An `auto` session, like any spawn with nothing pinned, checks every Kiro Crew ownership record in one locked pass before spawn. It removes only an effort value that still exactly matches its record, preserves unowned or operator-edited values, and drops stale records. Its concrete model is not known until after kiro-cli reads the overlay, and a model an earlier session served may not be the next one, so every recorded model must be considered. The lock-free existence check remains the first step, so an empty work directory gets neither an overlay nor a lock. + +The overlay is still one file per work directory. Inside one gateway, a spawn that leaves its model at the model default holds a work-directory fence from its projection until its runtime start returns, so no level can land in that window: no chat's pick, no live Default clear that writes a workspace default and no other session's level projection. It reaches that fence in three steps, holding nothing it does not need: it waits for writes in its work directory holding nothing at all, then queues for its gateway-wide cold-start permit holding nothing on the fence, and takes the fence only once it holds that permit and no write is held or waiting at that instant; a write that arrived while it queued gets the permit handed straight back, and the spawn waits for the directory again. So a start that is only queued holds no fence, and a start waiting for a directory holds no cold-start permit, which would otherwise make one busy work directory stall cold starts in every other project. The permit then covers the projection and the Tool Search write as well as the spawn, so a settings-lock wait either of them makes, at most the 2 s startup lock wait each, holds that cold-start slot too. Spawns that leave their model at the default share the window because each only clears. Arrival order decides the rest: a write waits only for spawns that hold the fence, from their projection until their runtime start returns, which still includes that start's `session/new` and the gate wait that request makes, because which step reads the file is not established. A spawn that is waiting holds off nothing, so a stream of Default starts cannot overtake a queued pick: each start that finds a write held or waiting goes back to waiting for it. It waits for the directory through the longest file-lock wait plus the startup-lock margin, a bound that counts only the time spent waiting for writes and never the start's turn in the cold-start queue; if that bound expires, the start fails instead of reading a file a gateway write could still change. A pick that meets the fence waits, up to the same ceiling it already waits for the file's lock, and then takes the same answer a held lock gives it. The fence is in-process and its end point is the whole runtime start, because which protocol step reads the file is not established: a second gateway on the same work directory, kiro-cli's own `settings --workspace` write, Code Review Sage and a hand edit are not held by it, and a level one of those writes between the projection and the read is still run by that session until its next spawn, because kiro-cli has no live "model default" to push. Closing that part needs a per-session overlay or effort carried in `session/new`, which is outside this PR. + +A level written by an older build has no `kirocrew.effortOwned` record and survives automatic clears. An explicit Default removes it for a concrete model that takes effort. On a live session the clear does it at once, and when its live push fails after the rewrite it asks for a reset instead, since a respawn applies the default (kiro-cli from the file, KAS as the start's `effortLevel` push); otherwise (no live session, a turn in flight, a clear that raised, a model without effort) the key's session-map entry carries the one-shot `explicit_effort_default` flag, and the key's first cold start skips the warm pool. A concrete pin removes that model's entry, or swaps it for a Kiro Crew default level, before spawning. An unpinned or `auto` start keeps the pre-spawn ownership guard and records the models whose effort entries remain under the key kiro-cli reads for each model when the spawn reads the file. That whole post-spawn path belongs to the harnesses that READ the overlay (`ACP_BACKENDS_EFFORT_OVERLAY`), the set every write, read-back and clear of those keys is gated on: on any other harness an unpinned start reads, records and rewrites nothing and spends the Default at the start exactly as a pinned start does, because its engine never opens `cli.json` and its own level arrives live (KAS as the `effortLevel` config option), so removing the entry would only take the level a kiro-cli chat in the same work dir spawns on, and the restart below would buy a second cold start for a file this session does not read. Once the backend reports the served model, an absent entry or a model that takes no effort needs no file change. A resolved workspace default replaces only the served model's entry and reaches the running session through the existing post-spawn `/effort` push. With no workspace default, the served model's recorded entry is removed. If the first session was fresh, the runtime is shut down and the first session's files are deleted once that runtime has stopped, because it ran no turn and is never resumed, and one replacement runtime repeats the first start's own resume decision after repeating the ownership-guarded projection under the spawn window. A first runtime that cannot be stopped fails the start; allocation's failure path kills it and leaves the flag armed. A brand-new chat therefore starts fresh again, while a Tool Search replay start takes the same fresh-session-plus-replay path. A session kiro-cli restores with `session/load` keeps the level it last ran at whatever the file says (measured on kiro-cli 2.29.0), so for it, as for a pinned start, the Default takes effect at its next fresh session. If the replacement serves a model that still has its own effort entry, the start fails, its failure path kills that runtime and leaves the flag armed, and no third runtime is started. An unknown served model, refused rewrite or failed live reapplication leaves the flag armed for the next start. The removal waits for that start because the start, not the pick, resolves the work directory and the model whose entry it removes, and a workspace switch, a cleared project or a model switch can change either after the pick. Every explicit Default that rewrites the file, live or at that start, does it inside a warm-pool fence: no warm runtime is claimed while the write is in progress, each one queued before the write settled is refused at its claim, and one claimed before the write began is discarded before its session is registered, because each may have read the removed entry at its own spawn. A start or live clear cancelled during the write lowers the fence only once the write has settled, and the start still records whether its replacement ran. A pinned start clears the flag only after the backend reports serving that exact pin. A pin the account cannot use, or any other served model, follows the unpinned completion path keyed on the served model. An unpinned or `auto` start clears it only when the served model needed no change, its workspace default was pushed successfully, or its replacement session read no entry for the model it served; an unknown model, a refused rewrite, a failed push or a replacement that still reads an effort entry leaves it for the next start. The pick saves the flag before it answers. A cold start reserves that flag in memory without changing it on disk, arms the provider, and clears and saves the flag only after the start reports that it applied the Default. A kill before that clear is saved leaves the flag armed, so the next cold start applies the Default again. The replay normally finds the entry already gone; a level written by hand between the killed start and the next one is replaced, just as one written between the pick and the first start is. A start whose overlay rewrite does not apply writes nothing and leaves the durable flag armed. A failed or cancelled start also leaves the flag armed unless the running session fully applied the Default. If a post-spawn rewrite landed before that failure, the next cold start safely repeats the rewrite and live reapplication. A failed save after an applied projection does not fail the start: the in-memory flag remains clear and the map's next save retries it. While a key's cold start is in flight, from its read of the flag until the session is registered, a pick on that key that would change the flag is refused with the retryable 409 a turn in flight gives (`turn_in_flight`) and changes nothing, so no pick can change the basis a start publishes on; a level pick on a key whose flag is already clear has nothing to record and lands, because the reservation is also held while a caller waits to claim the key's live session, which can last a whole turn; a pick that already reached the live session keeps that session's value and reports the unsaved intent as before. A pick that passed that check before the start took its reservation may still be saving its flag when the start reaches its read; the start then waits for that save to settle, so it reads either the saved value or the one a failed save put back, never a value no save kept. The write is counted under the session's canonical key, so the two spellings of one Slack thread share it. A level that cleared a pending Default before its push still puts it back when the pick does not commit, even while such a start is in flight, and the next cold start applies it. A pick whose flag cannot be saved is refused with 503 before anything changes, unless its level already reached the live session, which it then reports with a warning. A pick refused after the flag was saved puts the earlier choice back, and says so with a warning when that save fails. A level clears a pending flag before it is pushed to a live session, so that clear is saved before anything else changes, and every exit that does not leave the slot on the level puts the flag back. After a rebind during the switch, a pick the slot's new binding cannot save is refused the same way, after any session its push reached is back on the slot's value; when that session cannot be put back, the pick is kept and reported with a warning. The session map holds the flag for at most 1,000 sessions, each key at most 200 characters, and a pick it cannot hold is refused with the same 503. A pick that clears a session's flag keeps that row counted until it ends, so putting the flag back is never refused and no other session's pick can take the row meanwhile. A level `session_set_model` committed at the turn start clears a pending flag in the same step, and the turn saves it before acquiring a session; a later effort-picker choice on any slot driving that session that changes its explicit-Default flag (a Default pick, or a level pick that clears a pending Default) bumps every open slot driving the session and supersedes the call's effort half. A level pick with no Default pending does not. A failed or cancelled save puts the pick, the slot's values and the flag back, so the turn runs on the values it had and the next turn applies the pick again. A start on a level its caller chose runs that level and leaves a pending flag as it found it, neither arming nor clearing it: the level is not a newer effort action on the key (a level pick through the effort handler, or a level `session_set_model` commits at the turn start, clears the flag itself) but a slot's level read before the Default pick landed, an alias slot's own level, or a caller's pin, and the key's next start without an override applies and clears the flag. Such a start still takes the warm pool, where its level is pushed after the claim. The reader refuses a `cli.json` past a 1 MiB ceiling, a link, a hardlink, a non-regular file, or a file that escapes the canonical workspace settings directory: a write raises and leaves it unchanged, a clear returns failure, and a Default or `auto` spawn logs that it may run another session's level. Skill projection's read of the same file, capped at `MAX_FILE_BYTES` rather than that ceiling, refuses those shapes too: it skips the projection with a warning and leaves the file unchanged. An effort write or clear whose rewritten file would pass that ceiling is refused the same way, with the file unchanged; Kiro Crew's rewrites keep non-ASCII text as UTF-8 and escape it only where it cannot be encoded. The settings lock also refuses a settings folder on the sensitive-path list, or one whose real path cannot be verified, before it creates or opens anything beneath it, with the same outcomes; skill projection and Code Review Sage take that lock too. The reader also refuses a `cli.json` whose opened descriptor names a sensitive file or cannot be resolved. Where descriptor-relative writes are supported, the read and publish use the settings directory the lock opened, so a folder replaced or linked after the lock cannot receive the write and the writer raises. Elsewhere both use the by-name floor, so such a swap is not caught. On either path, a `cli.json` another writer changed after it was read is left as that writer left it: a write raises and a clear returns failure. The settings folder the lock opens is resolved component by component beneath the trusted work directory. Each `.kiro` or `settings` name is inspected with `lstat`; a link or junction is expanded from `readlink` text only. Relative and absolute targets are admitted only when their lexical path stays inside the resolved work directory. A remote, outside, rooted-on-another-drive, or looping target is refused without opening, resolving, or probing that target. A clear or Default in such a work directory reports that it could not change `cli.json`, whether or not the named target contains that file. The directories from the work dir down to an admitted folder are then walked by the names that admission produced and never through a link (`O_NOFOLLOW` per component on POSIX; create, pin and re-check each level on the by-name floor), so a part swapped for a link after admission is refused, as is a second admission that differs from the first. The lock file and the `cli.json` leaf are never followed, and every consumer takes the folder from the lock (`LockedCliSettings.settings_dir`), so an accepted link reads and writes the same folder it locked. The in-process spawn fence uses that same admitted folder as its key. When admission refuses a link, the fence key uses the resolved work directory plus the literal `.kiro/settings` names and never resolves the refused link. If the work directory itself cannot be resolved, the start fails rather than running under a different key from another chat in that work directory. Two work dirs sharing an admitted settings folder therefore exclude each other. The pre-spawn projection warns once, collecting bounded samples of escaped file-derived model names and values, listing at most 10 entries and then the count of remaining entries, when a Default or `auto` spawn may run such an unowned level instead of the model's default. To remove it, pick a concrete level in the effort picker and then pick Default, or hand-edit `cli.json`; an `auto` slot has to select the listed model first, and its warning says so; a slot already showing Default returns before the clear path. **MCP Tool Search** (kiro-family backends — see https://kiro.dev/docs/cli/mcp/tool-search/): loads MCP tool specs on demand ("search-and-call") instead of sending every tool definition each turn, keeping the context window clear when many MCP servers are configured. Gated by the `agent.tool_search` config toggle (default **on**; auto-surfaces as a Settings toggle since the schema is generated from the dataclass). -- **kiro-cli (Rust engine):** applied via the **same** workspace `cli.json` overlay used for effort (`/.kiro/settings/cli.json`), written deterministically before every spawn and on each restart by `_write_tool_search_overlay` (called from `AcpProvider.__init__` and `start()`), scoped to `ACP_BACKENDS_TOOL_SEARCH_OVERLAY`. The engine activates deferral only when the agent's `tools` also grants `tool_search`, so on this channel the loader invariant is the engine's. When enabled it writes the flat keys `toolSearch.enabled=true` plus `toolSearch.minPct`/`toolSearch.minTokens`, taken from `agent.tool_search_min_pct` / `agent.tool_search_min_tokens` (defaults `5` / `50000`, mirroring kiro-cli's own thresholds; clamped to 0-100 and >= 0, non-numeric falls back to the default); when disabled it writes `toolSearch.enabled=false` and drops both thresholds. +- **kiro-cli (Rust engine):** applied via the **same** workspace `cli.json` overlay used for effort (`/.kiro/settings/cli.json`), written deterministically before every spawn and on each restart by `_write_tool_search_overlay` (called from `AcpProvider.start()`), scoped to `ACP_BACKENDS_TOOL_SEARCH_OVERLAY`. The engine activates deferral only when the agent's `tools` also grants `tool_search`, so on this channel the loader invariant is the engine's. When enabled it writes the flat keys `toolSearch.enabled=true` plus `toolSearch.minPct`/`toolSearch.minTokens`, taken from `agent.tool_search_min_pct` / `agent.tool_search_min_tokens` (defaults `5` / `50000`, mirroring kiro-cli's own thresholds; clamped to 0-100 and >= 0, non-numeric falls back to the default); when disabled it writes `toolSearch.enabled=false` and drops both thresholds. - **Why the thresholds are not forced to 0:** deferral costs a round-trip — a deferred tool's spec is absent from the model's tool list, so the first direct call fails with `A tool with the name '' does not exist` and has to be recovered with `tool_search`. That only pays once the specs are genuinely large, which is what the thresholds express (kiro-cli defers when EITHER is exceeded). Setting both to `0` restores unconditional deferral for operators who want it. The thresholds are always written **explicitly** rather than omitted, so a value already in the file is replaced rather than silently kept. -- Writing both `true` and `false` makes the Kiro Crew toggle authoritative over any value in the user's global `~/.kiro/settings/cli.json`. The write is merge-safe with the effort `chat.modelDefaults` keys in the same file. +- Writing both `true` and `false` makes the Kiro Crew toggle authoritative over any value in the user's global `~/.kiro/settings/cli.json`. The write is merge-safe with the effort `chat.modelDefaults` keys in the same file. A `cli.json` that cannot be read (`OSError`) or cannot be parsed into a settings object is left byte-for-byte unchanged and the write raises; the provider logs the skipped write at WARNING and starts the session without it, so a corrupt overlay costs that spawn its Tool Search projection, never the operator's file or the session. - kiro-cli reads workspace settings only from `/.kiro/settings/cli.json`, so the overlay cannot move out of the work dir. When the work dir is a git work-tree top, the shared overlay lock adds the overlay and its lock to the repository's local `info/exclude`, keeping the user's `git status` clean (see [acp-client](acp-client.md)); an already-tracked `cli.json` is not hidden. - **KAS:** never opens `cli.json`; it reads the setting from the ACP `initialize` request, `clientCapabilities._meta.kiro.settings.toolSearch` — a process-wide channel filled by `AcpRuntime._handshake_client_capabilities`, entered from the spawn path only when the harness answers `client_meta_settings` (the harnesses in `ACP_BACKENDS_CLIENT_META_SETTINGS`; every other host reads its constant with no new step), from the same `agent.tool_search*` values (`agent_sdk/tool_search.py`). KAS defers **every** MCP spec when told to and does not check that a loader is mounted, so the client keeps the invariant: `enabled` is sent as `true` only when the spawn agent's spec grants `tool_search` — named in `tools`, or via `"*"` / `@builtin` — and does not take it back in `excludedTools`; it is sent as an explicit `false` otherwise — never omitted, so a host default cannot flip a session into "deferred, unreachable". The spec judged is the one the KAS projection puts on the wire (`AcpRuntime._projected_spawn_spec`: the freshness gate's snapshot for a derived agent, else `acp/harness/kas.resolve_projected_spec`, the call the projection itself makes, after the same `ensure_agent_materialized` self-heal the projection runs) — so a project checkout's `.kiro/agents` spec is judged exactly when it is the one projected; an unreadable spec grants nothing. The setting is process-wide, so every later `create_session` on a deferral-enabled process judges the payload it is about to send: a projection that grants no loader — another agent, or the same agent whose spec has since lost the grant — is refused with `AcpToolSurfaceBindingError` (an `AcpWorkspaceBindingError`), which the run-runtime caller already answers by giving that session a runtime of its own. The check lives in `AcpRuntime._kas_custom_agents`, the projection seam both `create_session` and `load_session` go through, so a resumed session is judged the same way. It is deliberately one-directional: a process whose handshake sent `enabled: false` may serve a later session whose agent does grant the loader — that session simply runs with full tool specs, today's behaviour and never a loss of tools — whereas the reverse pairing loses every MCP tool, which is the only shape worth a refusal. Every KAS-capable runtime constructor threads the same resolved settings — the foreground provider, its resume-respawn fallback, the companion-runtime kwargs mirror (`session_allocation._collect_parent_runtime_kwargs`, reading the parent's `LLMProvider.tool_search_settings` capability — default `None`, answered by `AcpProvider`; harness-parity H14) and the background runtime (`session_background`) — so no KAS process is left to the host's default. KAS does not honour the two thresholds as an activation floor — deferral there is all-or-nothing, with `toolSearch.neverDefer` as its keep-list — but they are forwarded verbatim so the setting means one thing. - **Non-kiro-family backends** — no-op on both channels. `_apply_tool_search_overlay` returns early when the backend does not read the overlay, the handshake channel is filled only for members of `ACP_BACKENDS_CLIENT_META_SETTINGS`, and both are silent when no toggle value was threaded in (`tool_search is None`). diff --git a/docs/system-specs/modules/session-control.md b/docs/system-specs/modules/session-control.md index bfc31dd43e1..42072aa46cf 100644 --- a/docs/system-specs/modules/session-control.md +++ b/docs/system-specs/modules/session-control.md @@ -24,7 +24,7 @@ unreachable in production because the caller's `X-Internal-Secret` is ignored. | `session_end_wait` | `POST /api/session-control/end-wait` | Wake a session the caller CREATED from the `wait` tool early, keeping its turn; any other target is refused `not_creator`, for every caller class | | `session_retry` | `POST /api/session-control/retry` | Re-run a failed last turn through `continue_slot_turn`, the Resume button's path, with `require_interrupted=True` (`turn_not_failed` otherwise) and `authorize_target` re-run synchronously under the slot lock before the continuation is queued | | `session_continue` | `POST /api/session-control/continue` | The same `continue_slot_turn` call without `require_interrupted`, as the Continue button makes it, through the helper `retry_target` shares (`_continue_turn_of_target`); the result's `body` is `resumed` or `continued`, whichever continuation `_is_interrupted` chose, and the allowed audit line records it. Not in any auto-grant: a member or conductor is asked before each call | -| `session_set_model` | `POST /api/session-control/set-model` | Record a pending model and/or reasoning-effort pick on an idle session; `apply_pending_model_pick` commits it at the start of the target's next turn after re-running `authorize_target` in the same synchronous step. Each half yields separately to a newer user pick from its own control. A busy target is refused with `target_busy` and keeps its settings | +| `session_set_model` | `POST /api/session-control/set-model` | Record a pending model and/or reasoning-effort pick on an idle session; `apply_pending_model_pick` commits it at the start of the target's next turn after re-running `authorize_target` in the same synchronous step. A committed effort level clears the key's pending explicit Default in that step. A later effort-picker choice on any slot driving that session that changes its explicit-Default flag (a Default pick, or a level pick that clears a pending Default) bumps every open slot driving the session and supersedes the pending effort half; a level pick with no Default pending does not. If saving the clear fails or is cancelled, the pick, slot values, and flag are restored so the current turn keeps its prior values and the next turn retries the pick. Each half otherwise yields separately to a newer user pick from its own control. A busy target is refused with `target_busy` and keeps its settings | | `session_set_color` | `POST /api/session-control/set-color` | Set the sidebar tint (palette index 0-6, or clear). `authorize_target` (self refused), then a creator fence for every caller: only sessions it created | | `session_reload` | `POST /api/session-control/reload` | Relaunch the agent process of an idle session the caller created, through `chat_handlers.reload_slot_session` (shared with the tab menu's Reload session). The transcript is kept and gets one notice naming the caller. Self, remote-crew and busy targets (turn running or starting, queued messages, sub-agents) are refused | | `session_rename` | `POST /api/session-control/rename` | Set the caller's own title or a target's through `chat_title.apply_manual_title`, the sidebar rename's code, after `authorize_target` with `allow_self`; a scheduled run's own session is still refused as `unattended_target`. The title is final (`_TITLE_ORIGIN_USER`). An empty title, one over 200 characters (before or after `_scrub_text` redaction, which lengthens credential-shaped text into markers), one with no character that renders (only whitespace, characters `terminal_safe._is_invisible` reports such as zero-width, variation-selector and Hangul-filler characters, or the blank Braille pattern U+2800), one containing U+2800 anywhere (scanning normalization keeps it, so it could split a secret past the redactors), or one with a control character or any text-direction control (embeddings, overrides, isolates and the U+200E/U+200F/U+061C marks), or any other `Cf` format character except U+200C/U+200D and the tag characters U+E0020..U+E007F that spell flag subdivisions (`_is_allowed_format_char`; the tag block's other `Cf` character, U+E0001 LANGUAGE TAG, is refused; its unassigned code points are not `Cf`), is refused with `invalid_title`; if the history write fails the live slot keeps and broadcasts the new title (best-effort, like the sidebar and channel renames; the next full save writes it) and the verb answers `title_persist_failed` (500) instead of `ok` | diff --git a/docs/system-specs/modules/session.md b/docs/system-specs/modules/session.md index 2957b8e81d3..9ad9b78401f 100644 --- a/docs/system-specs/modules/session.md +++ b/docs/system-specs/modules/session.md @@ -3242,7 +3242,63 @@ refused at the same gate, tightening a row already retained is never refused, and no retained row is ever evicted -- it is the record the channel gate hydrates from. Retiring rows once the header carries the mode needs the channel gate to read the header, a separate change. Immortality of durable -settings stays opt-in (`_DURABLE_FLAGS`). +settings stays opt-in (`_DURABLE_FLAGS`). `EXPLICIT_EFFORT_DEFAULT_FLAG` is one +of them: an explicit Default that could not remove the model's workspace +`cli.json` effort entry itself leaves it on the key, and the first cold start of +that key that applies it clears it. It is carried there, not applied at the +pick, because the cold start resolves the work directory and the model whose +entry it removes, and either can change after the pick. A start that applies +the flag clears it. A level `session_set_model` committed at the turn start +clears a pending flag in the same step, and the turn saves that clear before it +acquires a session. A later effort-picker choice on any slot driving the same +session that changes the session's explicit-Default flag (a Default pick, or a +level pick that clears a pending Default) bumps every open slot driving that +session and supersedes the call's effort half. A level pick with no Default +pending does not. A +failed or cancelled save puts the pick, the slot's values and the flag back; the +turn runs on the values it had, and the next turn applies the pick again. The +flag stays armed on disk until that start has applied the Default and the clear +is saved. A pinned start rewrites the model's entry before its spawn and clears +its flag only after the backend confirms it served that exact pin; otherwise it +rewrites the served model's entry after the spawn as for an unpinned start. An +unpinned start rewrites the served model's entry after its spawn, once the +backend names that model; a harness that never reads `cli.json` applies the +Default without changing the file (details in providers.md). A kill before +the saved clear therefore leaves the next cold start armed, so it applies the +Default again. That replay finds the entry already gone, unless a level was +written by hand after the killed start; that level is replaced just as one +written between the original pick and its first start is replaced. A start +carrying a `reasoning_effort_override` runs that level, arms nothing and leaves +the flag pending for the key's next start without an override: the override is +not a newer effort action on the key (a level pick through the effort handler, +or a level `session_set_model` commits at the turn start, clears the flag itself) +but a slot's level read before the Default pick landed, an alias slot's own +level (two slot objects can drive one session), or a caller's pin. +Explicit-Default rows are bounded, and a pick past that bound is refused +before it changes the slot. Clearing the flag on a row that holds nothing else +removes the row, so none is left outside that bound. A pick that clears a key's flag holds its row until it ends +(`SessionMap.hold_explicit_effort_default`), so putting the flag back after a +failed save or a rollback needs no new row and cannot be refused. + +Dashboard eager prefetch takes the session-keyed switch lock from its slot effort +read until `get_or_create` holds the key's allocation reservation, handing it off +at the cold-start gate's entry (`on_gate_queued`, the first hook past the +reservation). A concurrent effort picker therefore either commits before the +prefetch re-checks its captured bindings, causing that prefetch to stand down, or +waits until the reservation is held, where a pick that would change the effort +basis is refused (`effort_basis_locked`) until the allocation has registered. +Same-key starts that both read a pending explicit Default are serialized: the +first reserves it before the pool decision, and the others wait, without an +allocation reservation, until it registers or fails, then retry. A pick can land +between the owner's release and a waiter's retry; the retry re-reads the effort +basis, so it acts on that newest pick. It cannot record `explicit_effort_default` between a stale level read and the +allocation reservation. The lock is not held across the gate wait itself: a +person's first turn takes this lock before it allocates, and a prefetch queued +behind the BACKGROUND cold-start cap would otherwise make that turn wait out the +prefetch's whole start instead of running its own FOREGROUND start beside it. +The prefetch passes `wait_if_busy=False` while holding this lock: if a real turn +registered first, `SessionBusyError` makes speculation stand down rather than wait +for a lease under a lock the live turn's refusal fallback may need. **Mapped-session enumeration:** `SessionMap.mapped_sids_by_key()` returns session key → kiro-cli session ID for every entry that has one. Disk accounting diff --git a/src/kiro_crew/acp/runtime_spawn_entry.py b/src/kiro_crew/acp/runtime_spawn_entry.py index 6c2cc543113..025995b73aa 100644 --- a/src/kiro_crew/acp/runtime_spawn_entry.py +++ b/src/kiro_crew/acp/runtime_spawn_entry.py @@ -19,8 +19,11 @@ from __future__ import annotations import asyncio +import contextvars import logging import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Callable from kiro_crew import runtime_death @@ -35,6 +38,99 @@ logger = logging.getLogger("kiro_crew.acp.runtime") +class ColdStartPermit: + """One cold-start admission permit, released once by whichever owner ends it. + + The Default-branch lifecycle takes a start's permit BEFORE it opens the + work-directory overlay window, so a start that is still only queued holds no + window and a level pick in that directory is not held off by it. While it + queues it registers nothing on that fence either, so a write in the directory + waits for no part of the queue wait. The permit is then handed to the first + ``spawn`` of that start, which must not queue for a second one. + + One flag decides the single release, so a double release is impossible by + construction: the spawn that consumed the permit releases it when it + finishes and the lifecycle's own release is then a no-op, while a start that + failed before any spawn consumed it is released by the lifecycle. + + Only the task that created it may claim it. A task started under the window, + by a runtime start or by anything it calls, inherits the ContextVar's context + and would otherwise find this permit: it would spawn on another start's + admission, or on one already released, and so skip admission control + altogether. + """ + + def __init__( + self, + admission: runtime_start._ColdStartAdmission, + start_priority: StartPriority, + wait_ms: float, + ) -> None: + self.admission = admission + self.wait_ms = wait_ms + self._start_priority = start_priority + self._released = False + self._owner_task = asyncio.current_task() + + def claimable(self) -> bool: + """Is this permit still live, and is the caller the task that took it?""" + return not self._released and self._owner_task is asyncio.current_task() + + def release(self) -> None: + if self._released: + return + self._released = True + self.admission.release(self._start_priority) + + +_handed_down_cold_start_permit: contextvars.ContextVar[ColdStartPermit | None] = ( + contextvars.ContextVar("kiro_crew_handed_down_cold_start_permit", default=None) +) + + +def _consume_handed_down_cold_start_permit() -> ColdStartPermit | None: + permit = _handed_down_cold_start_permit.get() + if permit is None or not permit.claimable(): + # Not this task's permit, or already released: queue for its own permit + # rather than run on one this spawn does not own. The ContextVar is left + # alone, because a child task's set would not reach the owner anyway. + return None + # Cleared as it is consumed, so the respawn of a runtime that died during + # resume queues for its own permit rather than running on this one. + _handed_down_cold_start_permit.set(None) + return permit + + +@asynccontextmanager +async def handed_down_cold_start_admission( + start_priority: StartPriority, + *, + on_gate_queued: Callable[..., None] | None = None, + on_gate_acquired: Callable[..., None] | None = None, +) -> AsyncIterator[ColdStartPermit]: + """Hold this start's admission permit for the first ``spawn`` beneath it. + + A ContextVar and not a ``spawn`` keyword, because the lifecycle does not call + ``spawn``: it awaits a zero-argument ``start_runtime`` closure that reaches it + through ``_start_kiro_runtime``, which twenty of the provider's test files + stub. A keyword would have to be threaded through that stubbed seam and every + stub would have to accept and forward it, while the ContextVar travels the + same task with no signature in between, and a stub that never spawns simply + leaves the permit for this manager to release. + """ + admission = runtime_start._cold_start_admission() + notify_start_queue(logger, on_gate_queued, START_QUEUE_ADMISSION) + wait_ms = await admission.acquire(start_priority) + permit = ColdStartPermit(admission, start_priority, wait_ms) + token = _handed_down_cold_start_permit.set(permit) + try: + notify_start_queue(logger, on_gate_acquired, wait_ms, START_QUEUE_ADMISSION) + yield permit + finally: + _handed_down_cold_start_permit.reset(token) + permit.release() + + async def spawn( self: AcpRuntime, *, @@ -55,13 +151,22 @@ async def spawn( self._process_tree_confirmed_dead = False admission = runtime_start._cold_start_admission() - notify_start_queue(logger, on_gate_queued, START_QUEUE_ADMISSION) - wait_ms = await admission.acquire(start_priority) + handed_down = _consume_handed_down_cold_start_permit() + if handed_down is None: + notify_start_queue(logger, on_gate_queued, START_QUEUE_ADMISSION) + wait_ms = await admission.acquire(start_priority) + else: + # The lifecycle queued for this start's permit and is holding it, so it + # already reported the queue wait. Acquiring again would queue behind a + # permit this start itself holds. + admission = handed_down.admission + wait_ms = handed_down.wait_ms started = time.monotonic() outcome = "error" try: - # Inside the try: the permit is released below whatever the callback does. - notify_start_queue(logger, on_gate_acquired, wait_ms, START_QUEUE_ADMISSION) + # Inside the try: an owned permit is released below whatever the callback does. + if handed_down is None: + notify_start_queue(logger, on_gate_acquired, wait_ms, START_QUEUE_ADMISSION) logger.info( "acp_cold_start stage=queue_wait outcome=admitted priority=%s wait_ms=%.1f " "active_starts=%d queued_starts=%d %s", @@ -94,7 +199,10 @@ async def spawn( admission.queued, process_state, ) - admission.release(start_priority) + if handed_down is None: + admission.release(start_priority) + else: + handed_down.release() async def _spawn_admitted_rederiving_once(self: AcpRuntime) -> None: diff --git a/src/kiro_crew/acp/skill_projection.py b/src/kiro_crew/acp/skill_projection.py index c339b757430..b632a828d7b 100644 --- a/src/kiro_crew/acp/skill_projection.py +++ b/src/kiro_crew/acp/skill_projection.py @@ -36,11 +36,28 @@ kiro_home, project_agents_dir, ) -from kiro_crew.hooks import FileTooLargeError, safe_read_file_bytes # noqa: F401 +from kiro_crew.hooks import ( # noqa: F401 + MAX_FILE_BYTES, + FileTooLargeError, + safe_read_file_bytes, + safe_read_file_bytes_nolink, +) from kiro_crew.security import _PATH_RESOLVE_TIMEOUT_SECS # noqa: F401 from kiro_crew.user_json import copy_json_document # noqa: F401 from kiro_crew.validation import is_registered_agent_name # noqa: F401 -from kiro_crew.workspace_cli_settings import workspace_cli_settings_lock # noqa: F401 +from kiro_crew.workspace_cli_settings import ( # noqa: F401 + CLI_JSON_REFUSED, + LockedCliSettings, + admitted_workspace_cli_settings_dir, + carry_effort_ownership, + locked_workspace_cli_settings, + owned_document_unchanged, + pinned_cli_settings_fd, + publish_locked_cli_settings, + read_pinned_cli_json, + settings_json_text, + workspace_cli_settings_lock, +) logger = logging.getLogger(__name__) @@ -108,6 +125,7 @@ _restore_inheritance, _settings, _settings_read_sleep, + _settings_with_mtime, inherits_default_resources, ) from kiro_crew.acp.skill_projection_parts.native_projection import ( # noqa: F401 diff --git a/src/kiro_crew/acp/skill_projection_parts/inheritance_overlay.py b/src/kiro_crew/acp/skill_projection_parts/inheritance_overlay.py index c3a9a6619c2..b1032d8f438 100644 --- a/src/kiro_crew/acp/skill_projection_parts/inheritance_overlay.py +++ b/src/kiro_crew/acp/skill_projection_parts/inheritance_overlay.py @@ -17,10 +17,25 @@ from pathlib import Path from typing import Any +from kiro_crew import platform_compat from kiro_crew.atomic_write import atomic_write, on_event_loop from kiro_crew.config.paths import kiro_home -from kiro_crew.hooks import FileTooLargeError, safe_read_file_bytes +from kiro_crew.hooks import ( + MAX_FILE_BYTES, + FileTooLargeError, + safe_read_file_bytes, + safe_read_file_bytes_nolink, +) from kiro_crew.security import _PATH_RESOLVE_TIMEOUT_SECS +from kiro_crew.workspace_cli_settings import ( + CLI_JSON_REFUSED, + LockedCliSettings, + carry_effort_ownership, + pinned_cli_settings_fd, + publish_locked_cli_settings, + read_pinned_cli_json, + settings_json_text, +) # Logs under the facade's name, which operators and tests filter on. logger = logging.getLogger("kiro_crew.acp.skill_projection") @@ -48,9 +63,24 @@ _settings_read_sleep = time.sleep -def _settings(path: Path, *, may_pause: bool = True) -> dict[str, Any]: +def _settings(path: Path | LockedCliSettings, *, may_pause: bool = True) -> dict[str, Any]: """Read one Kiro ``cli.json`` through the credential gate; absence reads ``{}``. + See :func:`_settings_with_mtime` for the retry and pause rules. + """ + return _settings_with_mtime(path, may_pause=may_pause)[0] + + +def _settings_with_mtime( + path: Path | LockedCliSettings, *, may_pause: bool = True +) -> tuple[dict[str, Any], int | None]: + """Read one Kiro ``cli.json`` and the whole-second mtime of the file version read. + + The mtime is what :func:`carry_effort_ownership` validates Kiro Crew's effort + ownership record against when the document is written back; it is taken by a + no-follow stat right after the read, and is ``None`` for an absent file or + when the stat fails, which voids the record (the conservative side). + Off the event loop a refused read is retried on :data:`_SETTINGS_READ_RETRY_PAUSES`: one immediate re-check, then one pause and a final attempt. Every attempt re-runs the whole gate, so a path that @@ -61,12 +91,85 @@ def _settings(path: Path, *, may_pause: bool = True) -> dict[str, Any]: ``may_pause=False`` keeps the immediate re-check but drops the pause, for a caller holding a cross-process lock whose contenders give up after the same 2 s: sleeping there would make a concurrent spawn lose its lock instead. + + A locked read refuses every unsafe shape: a link, a hardlink, a non-regular + file, a descriptor whose real path escapes the settings directory, is + sensitive or cannot be resolved, and a read that fails. Once retries are + exhausted, a locked read raises ``OSError`` when its last attempt was + refused or saw the file change. Every other exhausted read raises + ``ValueError``. """ + cli_json = path.cli_json if isinstance(path, LockedCliSettings) else path + locked_refusal: OSError | None = None + locked_changed_while_read = False - def read_once() -> bytes | object | None: - if not path.exists(): + def read_once() -> tuple[bytes, int | None] | object | None: + nonlocal locked_changed_while_read, locked_refusal + locked_refusal = None + locked_changed_while_read = False + if isinstance(path, LockedCliSettings): + settings_fd = pinned_cli_settings_fd(path) + if settings_fd is not None: + try: + result = read_pinned_cli_json(settings_fd, cli_json, max_bytes=MAX_FILE_BYTES) + except OSError as exc: + locked_refusal = exc + return None + if result is None: + return _ABSENT_SETTINGS + raw, before_read, after_read = result + before_version = ( + before_read.st_dev, + before_read.st_ino, + before_read.st_size, + before_read.st_mtime_ns, + before_read.st_ctime_ns, + ) + after_version = ( + after_read.st_dev, + after_read.st_ino, + after_read.st_size, + after_read.st_mtime_ns, + after_read.st_ctime_ns, + ) + if before_version != after_version: + locked_changed_while_read = True + return None + if len(raw) > MAX_FILE_BYTES: + raise FileTooLargeError( + f"File exceeds {MAX_FILE_BYTES // (1024 * 1024)} MB safety cap" + ) + return raw, int(after_read.st_mtime) + if not os.path.lexists(cli_json): + return _ABSENT_SETTINGS + refusal = OSError(f"workspace cli.json overlay {cli_json} {CLI_JSON_REFUSED}") + if platform_compat.is_link_or_junction(cli_json): + locked_refusal = refusal + return None + raw_by_name = safe_read_file_bytes_nolink( + str(cli_json), + within_root=str(path.settings_dir), + max_bytes=MAX_FILE_BYTES, + within_root_is_canonical=True, + ) + if raw_by_name is None: + locked_refusal = refusal + return None + try: + file_mtime: int | None = int(os.stat(cli_json, follow_symlinks=False).st_mtime) + except OSError: + file_mtime = None + return raw_by_name, file_mtime + if not cli_json.exists(): return _ABSENT_SETTINGS - return safe_read_file_bytes(str(path)) + raw_by_name = safe_read_file_bytes(str(cli_json)) + if raw_by_name is None: + return None + try: + file_mtime = int(os.stat(cli_json, follow_symlinks=False).st_mtime) + except OSError: + file_mtime = None + return raw_by_name, file_mtime raw = read_once() if raw is None and not on_event_loop(): @@ -79,14 +182,23 @@ def read_once() -> bytes | object | None: if raw is not None: break if raw is None: - raise ValueError(f"Cannot read Kiro settings at {path}") - if not isinstance(raw, bytes): + if isinstance(path, LockedCliSettings): + if locked_refusal is not None: + raise locked_refusal + if locked_changed_while_read: + raise OSError( + f"workspace cli.json overlay {cli_json} changed while it was read; " + "left unchanged" + ) + raise ValueError(f"Cannot read Kiro settings at {cli_json}") + if not isinstance(raw, tuple): # The absence sentinel: the file does not exist. - return {} - data = json.loads(raw) + return {}, None + content, file_mtime = raw + data = json.loads(content) if not isinstance(data, dict): - raise ValueError(f"Kiro settings must be an object: {path}") - return data + raise ValueError(f"Kiro settings must be an object: {cli_json}") + return data, file_mtime def _inheritance_preference( @@ -139,8 +251,14 @@ def inherits_default_resources(work_dir: str | os.PathLike[str] | None) -> bool: return _inheritance_preference(local, global_settings)[0] -def _restore_inheritance(path: Path, local: dict[str, Any]) -> None: - """Undo only our overlay; a changed or removed native setting wins.""" +def _restore_inheritance( + settings: LockedCliSettings, local: dict[str, Any], file_mtime: int | None +) -> None: + """Undo only our overlay; a changed or removed native setting wins. + + *file_mtime* is the mtime of the file version *local* was read from, so the + write carries Kiro Crew's effort ownership record exactly as it stood. + """ inherited = local.get(_MANAGED_SETTING) source = local.get(_INHERIT_SOURCE) if not isinstance(inherited, bool) or source not in ("local", "global"): @@ -154,7 +272,7 @@ def _restore_inheritance(path: Path, local: dict[str, Any]) -> None: or not isinstance(previous.get("present"), bool) or (previous["present"] and "value" not in previous) ): - raise ValueError(f"Cannot restore Crew's inheritance overlay at {path}") + raise ValueError(f"Cannot restore Crew's inheritance overlay at {settings.cli_json}") if local.get(_INHERIT_SETTING) is True: if previous["present"]: local[_INHERIT_SETTING] = previous["value"] @@ -162,4 +280,7 @@ def _restore_inheritance(path: Path, local: dict[str, Any]) -> None: local.pop(_INHERIT_SETTING, None) for key in (_MANAGED_SETTING, _INHERIT_SOURCE, _PREVIOUS_INHERITANCE): local.pop(key, None) - atomic_write(path, json.dumps(local, indent=2)) + stamp_ns = carry_effort_ownership(local, file_mtime) + publish_locked_cli_settings( + settings, settings_json_text(local), write=atomic_write, mtime_ns=stamp_ns + ) diff --git a/src/kiro_crew/acp/skill_projection_parts/preparation.py b/src/kiro_crew/acp/skill_projection_parts/preparation.py index dc65c2c994f..440849137a5 100644 --- a/src/kiro_crew/acp/skill_projection_parts/preparation.py +++ b/src/kiro_crew/acp/skill_projection_parts/preparation.py @@ -34,6 +34,7 @@ _inheritance_preference, _restore_inheritance, _settings, + _settings_with_mtime, ) from kiro_crew.acp.skill_projection_parts.native_projection import ( NativeSkillProjection, @@ -83,7 +84,14 @@ from kiro_crew.atomic_write import atomic_write from kiro_crew.config.paths import data_home, kiro_agents_dir, kiro_home, project_agents_dir from kiro_crew.user_json import copy_json_document -from kiro_crew.workspace_cli_settings import workspace_cli_settings_lock +from kiro_crew.workspace_cli_settings import ( + admitted_workspace_cli_settings_dir, + carry_effort_ownership, + locked_workspace_cli_settings, + owned_document_unchanged, + publish_locked_cli_settings, + settings_json_text, +) # Logs under the facade's name, which operators and tests filter on. logger = logging.getLogger("kiro_crew.acp.skill_projection") @@ -307,7 +315,10 @@ def prepare_native_skill_projection( """Prepare native views after spec freshness admission, before spawning. Uses the existing workspace CLI settings channel. No home, identity store, - session store or authored agent file is relocated or rewritten. + session store or authored agent file is relocated or rewritten. The disabled + rollback admits the settings folder before its lock-free existence probe. If + a link names an outside or remote target, that target is not inspected and + the existing locked rollback reports that the file could not be changed. ``per_session_element`` says how ``kirocrew-core`` reaches a search agent's sessions with this session's identity. ``True`` is the shared runtime: the @@ -393,14 +404,22 @@ def prepare_native_skill_projection( "skill projection: ceiling-fallback prune skipped; lock or I/O error", exc_info=True, ) - if not (work_dir / ".kiro" / "settings" / "cli.json").exists(): + admitted_settings_dir = admitted_workspace_cli_settings_dir(work_dir) + if admitted_settings_dir is not None and not (admitted_settings_dir / "cli.json").exists(): return None try: - with workspace_cli_settings_lock(work_dir) as locked_settings: - _restore_inheritance(locked_settings, _settings(locked_settings, may_pause=False)) + with locked_workspace_cli_settings(work_dir) as locked_settings: + _restore_inheritance( + locked_settings, *_settings_with_mtime(locked_settings, may_pause=False) + ) + except RecursionError: + logger.warning( + "skill projection: workspace settings document is too deeply nested during " + "rollback; rollback skipped and file left unchanged" + ) except OSError: logger.warning( - "skill projection: workspace settings lock unavailable during rollback", + "skill projection: workspace settings unavailable during rollback", exc_info=True, ) return None @@ -481,13 +500,14 @@ def prepare_native_skill_projection( return None with alias_lock: try: - settings_lock = workspace_cli_settings_lock(work_dir) + settings_lock = locked_workspace_cli_settings(work_dir) with settings_lock as locked_settings: # This is the authoritative read for both projected resources and # the write below. Every in-product workspace cli.json writer uses # the same sidecar lock, so no effort or Tool Search update can land # between this read and commit. - local = _settings(locked_settings, may_pause=False) + local, local_mtime = _settings_with_mtime(locked_settings, may_pause=False) + read_local = copy_json_document(local) inherited, preference_source, overlaid = _inheritance_preference( local, global_settings ) @@ -584,7 +604,21 @@ def prepare_native_skill_projection( local[_MANAGED_SETTING] = inherited local[_INHERIT_SOURCE] = preference_source local[_INHERIT_SETTING] = True - atomic_write(locked_settings, json.dumps(local, indent=2)) + # This republishes the whole document, effort keys included. + # Kiro Crew's effort ownership record counts only while its + # stamp equals the file's mtime, so the rewrite restamps a + # record that was valid in the version read and drops one that + # was not, instead of voiding the former or re-validating the latter. + # A document equal to the owned file read, apart from the stamp, + # is not written: the file keeps its mtime and the record stays valid. + if not owned_document_unchanged(local, read_local, local_mtime): + stamp_ns = carry_effort_ownership(local, local_mtime) + publish_locked_cli_settings( + locked_settings, + settings_json_text(local), + write=atomic_write, + mtime_ns=stamp_ns, + ) prepared = NativeSkillProjection( aliases, specs, @@ -608,10 +642,18 @@ def prepare_native_skill_projection( except BaseException: lease_stack.close() raise + except RecursionError: + logger.warning( + "skill projection: workspace settings document is too deeply nested; left " + "unchanged, retaining aliases and using authored agents" + ) + return None except OSError as exc: - # Same routine contention as the alias-lock fallback above: the - # OSError names the real wait, so one warning line suffices and a - # traceback would only repeat the lock-helper frames. + # The OSError names its own cause: an unavailable lock, a refused or + # changing settings file, a settings folder that changed around the + # publish, or an alias write that failed. One warning line suffices; + # a traceback would obscure that cause with lock and file helper + # frames. logger.warning( "skill projection: workspace settings or lease lock unavailable (%s); retaining " "aliases and using authored agents", diff --git a/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/review_pool.py b/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/review_pool.py index 265647528a4..adf34df6625 100644 --- a/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/review_pool.py +++ b/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/review_pool.py @@ -32,6 +32,7 @@ from __future__ import annotations import asyncio +import copy import json import logging import os @@ -88,16 +89,42 @@ def refusal_for( STOP_REASON_TOOL_STALL = "error: tool stall" # type: ignore[assignment] try: - from kiro_crew.workspace_cli_settings import workspace_cli_settings_lock + from kiro_crew.workspace_cli_settings import ( + EFFORT_OWNED_KEY, + effort_ownership_stamp_matches, + locked_workspace_cli_settings, + owned_document_unchanged, + settings_json_text, + stamp_effort_ownership, + ) except ImportError: # pragma: no cover - standalone app fallback + EFFORT_OWNED_KEY = "kirocrew.effortOwned" @contextmanager - def workspace_cli_settings_lock( + def locked_workspace_cli_settings( work_dir: Path, *, timeout: float = 0.0 - ) -> Iterator[Path]: + ) -> Iterator[Any]: raise OSError("shared workspace CLI settings lock is unavailable") yield work_dir # pragma: no cover - marks this function as a context manager + # Standalone mode keeps the module importable and nothing more: the lock + # above refuses, so no overlay is ever written here. Should that change, + # these refuse ownership (every record is void and nothing is stamped), + # which is the conservative side of the stamp rule. + def effort_ownership_stamp_matches(document: dict[str, Any], file_mtime: int | None) -> bool: + return False + + def owned_document_unchanged( + document: dict[str, Any], read_document: dict[str, Any], file_mtime: int | None + ) -> bool: + return False + + def stamp_effort_ownership(document: dict[str, Any]) -> int | None: + raise OSError("effort ownership stamping is unavailable") + + def settings_json_text(document: dict[str, Any]) -> str: + return json.dumps(document, indent=2) + try: # agents dir resolver — honors KIRO_HOME so a pod reads its own specs from kiro_crew.config.paths import kiro_agents_dir @@ -445,11 +472,14 @@ def _write_effort_overlay(work_dir: str, model: str, effort: str = REVIEW_EFFORT {"chat.modelDefaults": {"": {"output_config": {"effort": ""}}}} - Inlined here (stdlib-only) so the app stays self-contained and the unit test is - hermetic. Merge-safe + idempotent; best-effort (logs and continues on error so a - bad overlay write never breaks a review).""" + The merge is inlined here so the app stays self-contained and the unit test is + hermetic; the ownership record's stamp rule comes from the one shared + implementation in ``kiro_crew.workspace_cli_settings``. Merge-safe + idempotent; + best-effort (logs and continues on error so a bad overlay write never breaks a + review).""" try: - with workspace_cli_settings_lock(Path(work_dir)) as cli_json: + with locked_workspace_cli_settings(Path(work_dir)) as settings: + cli_json = settings.cli_json # Through `read_json_nolink`, not `read_text`: `cli.json` sits under # the review worker's own `work_dir`, and the settings lock verifies # the LOCK file, never this one. A plain read dereferences a link @@ -459,6 +489,25 @@ def _write_effort_overlay(work_dir: str, model: str, effort: str = REVIEW_EFFORT # Missing, a plant, oversize and a non-object all arrive as None and # mean "no overlay yet", which is what an empty file means here too. existing = store.read_json_nolink(cli_json, cli_json.parent) or {} + read_document = copy.deepcopy(existing) + file_mtime = ( + int(cli_json.stat(follow_symlinks=False).st_mtime) + if cli_json.exists(follow_symlinks=False) + else None + ) + raw_owned = existing.get(EFFORT_OWNED_KEY) + owned = ( + { + owned_model: owned_effort + for owned_model, owned_effort in raw_owned.items() + if isinstance(owned_model, str) + and owned_model + and isinstance(owned_effort, str) + and owned_effort + } + if effort_ownership_stamp_matches(existing, file_mtime) and isinstance(raw_owned, dict) + else {} + ) defaults = existing.get("chat.modelDefaults") if not isinstance(defaults, dict): defaults = {} @@ -480,7 +529,27 @@ def _write_effort_overlay(work_dir: str, model: str, effort: str = REVIEW_EFFORT # REPLACED by a real file rather than written through, and the file it # pointed at keeps its bytes. A reader never sees a half-written # overlay either, because the rename publishes it whole. - store.atomic_write_text(cli_json, json.dumps(existing, indent=2)) + if effort: + owned[model] = effort + else: + owned.pop(model, None) + if owned: + existing[EFFORT_OWNED_KEY] = owned + else: + existing.pop(EFFORT_OWNED_KEY, None) + # A document equal to the owned file read, apart from the stamp, is + # not written: the file keeps its mtime and the record stays valid. + if owned_document_unchanged(existing, read_document, file_mtime): + return + # The stamp goes onto the staged inode before the rename, so the + # record and the file version that validates it appear together. A + # document with no record carries no stamp and takes a natural mtime. + stamp_ns = stamp_effort_ownership(existing) + store.atomic_write_text( + cli_json, + settings_json_text(existing), + mtime_ns=stamp_ns, + ) except Exception: logger.debug("could not write review effort overlay (work_dir=%s)", work_dir, exc_info=True) @@ -558,9 +627,12 @@ async def _ensure_runtime_locked(self) -> "AcpRuntime": # backend that reads the cli.json overlay gets one. if self._work_dir and backend in ACP_BACKENDS_EFFORT_OVERLAY: try: - _write_effort_overlay( - self._work_dir, _reviewer_model(self._agent), - _get_review_settings().get("effort", _DEFAULT_EFFORT)) + await asyncio.to_thread( + _write_effort_overlay, + self._work_dir, + _reviewer_model(self._agent), + _get_review_settings().get("effort", _DEFAULT_EFFORT), + ) except Exception: logger.debug("could not write review effort overlay", exc_info=True) # work_dir + sandbox_mode="auto" mirror the old AcpClient worker: the diff --git a/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/store.py b/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/store.py index 9417ced4621..60e4b586ac5 100644 --- a/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/store.py +++ b/src/kiro_crew/apps/builtins/code_review_sage/sage_lib/store.py @@ -415,7 +415,32 @@ def _write_all(fd: int, data: bytes) -> None: view = view[written:] -def atomic_write_locked(path: str | os.PathLike, data: bytes) -> None: +def _set_staged_mtime( + fd: int, + temp_path: str | os.PathLike, + mtime_ns: int | None, + *, + dir_fd: int | None = None, +) -> None: + if mtime_ns is None: + return + timestamp = (mtime_ns, mtime_ns) + if os.utime in os.supports_fd: + os.utime(fd, ns=timestamp) + elif dir_fd is None: + os.utime(temp_path, ns=timestamp) + elif os.utime in os.supports_dir_fd: + os.utime(temp_path, ns=timestamp, dir_fd=dir_fd) + else: # pragma: no cover - no supported platform has this capability gap + raise NotImplementedError( + "stamping a descriptor-relative atomic write requires " + "descriptor- or dir_fd-capable os.utime" + ) + + +def atomic_write_locked( + path: str | os.PathLike, data: bytes, *, mtime_ns: int | None = None +) -> None: """Write *data* to *path* atomically, resolving the parent directory ONCE. Every writer in this app stages a private temp file beside its target and @@ -505,6 +530,7 @@ def atomic_write_locked(path: str | os.PathLike, data: bytes) -> None: try: try: _write_all(fd, data) + _set_staged_mtime(fd, tmp, mtime_ns) finally: os.close(fd) os.replace(tmp, target) @@ -533,6 +559,7 @@ def atomic_write_locked(path: str | os.PathLike, data: bytes) -> None: try: try: _write_all(fd, data) + _set_staged_mtime(fd, tmp_name, mtime_ns, dir_fd=dir_fd) finally: os.close(fd) os.rename(tmp_name, target.name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd) @@ -576,9 +603,11 @@ def atomic_write_locked(path: str | os.PathLike, data: bytes) -> None: os.close(dir_fd) -def atomic_write_text(path: str | os.PathLike, text: str) -> None: +def atomic_write_text( + path: str | os.PathLike, text: str, *, mtime_ns: int | None = None +) -> None: """UTF-8 convenience wrapper over :func:`atomic_write_locked`.""" - atomic_write_locked(path, text.encode("utf-8")) + atomic_write_locked(path, text.encode("utf-8"), mtime_ns=mtime_ns) def _refuse_unsafe_leaf(target: Path) -> None: diff --git a/src/kiro_crew/apps/builtins/code_review_sage/tests/test_review_pool.py b/src/kiro_crew/apps/builtins/code_review_sage/tests/test_review_pool.py index 1e35c48edc1..e58ea679e71 100644 --- a/src/kiro_crew/apps/builtins/code_review_sage/tests/test_review_pool.py +++ b/src/kiro_crew/apps/builtins/code_review_sage/tests/test_review_pool.py @@ -167,6 +167,22 @@ async def test_begin_batch_spawns_one_runtime_shared_across_sends(self): self.assertEqual(len(FakeRuntime.instances), 1) await pool.end_batch() + async def test_effort_overlay_write_runs_off_event_loop_thread(self): + _install_fake_runtime(self) + loop_thread = threading.current_thread() + writer_threads = [] + + def _record_writer_thread(*_args, **_kwargs): + writer_threads.append(threading.current_thread()) + + with unittest.mock.patch.object(rp, "_write_effort_overlay", _record_writer_thread): + pool = ReviewPool(work_dir=_work_dir(self)) + await pool.begin_batch() + await pool.end_batch() + + self.assertEqual(len(writer_threads), 1) + self.assertIsNot(writer_threads[0], loop_thread) + async def test_end_batch_kills_runtime_only_when_drained(self): _install_fake_runtime(self) pool = ReviewPool(work_dir=_work_dir(self)) @@ -590,6 +606,113 @@ def test_write_effort_overlay_is_merge_safe(self): def test_write_effort_overlay_never_raises(self): _write_effort_overlay("/proc/nonexistent/\x00bad", "claude-sonnet-4.6") + def test_write_effort_overlay_keeps_non_ascii_text_unescaped(self): + with tempfile.TemporaryDirectory() as tmp: + settings = Path(tmp) / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / "cli.json").write_text( + json.dumps({"operator.note": "界"}, ensure_ascii=False), encoding="utf-8" + ) + _write_effort_overlay(tmp, "claude-sonnet-4.6", "high") + raw = (settings / "cli.json").read_bytes() + self.assertIn("界".encode("utf-8"), raw) + self.assertNotIn(b"\\u754c", raw) + + def test_write_effort_overlay_keeps_an_unpaired_surrogate_escaped(self): + with tempfile.TemporaryDirectory() as tmp: + settings = Path(tmp) / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / "cli.json").write_text('{"operator.note": "\\ud800"}', encoding="utf-8") + _write_effort_overlay(tmp, "claude-sonnet-4.6", "high") + data = json.loads((settings / "cli.json").read_bytes()) + self.assertEqual(data["operator.note"], "\ud800") + self.assertEqual( + data["chat.modelDefaults"]["claude-sonnet-4.6"]["output_config"]["effort"], + "high") + + def test_a_repeat_write_leaves_the_owned_file_untouched(self): + from types import SimpleNamespace + + from kiro_crew import workspace_cli_settings + + clock = [1_790_000_000.0] + with tempfile.TemporaryDirectory() as tmp, unittest.mock.patch.object( + workspace_cli_settings, "time", SimpleNamespace(time=lambda: clock[0]) + ): + _write_effort_overlay(tmp, "claude-sonnet-4.6", "high") + cli = Path(tmp) / ".kiro" / "settings" / "cli.json" + before = cli.read_bytes() + before_stat = cli.stat() + clock[0] += 10 + + _write_effort_overlay(tmp, "claude-sonnet-4.6", "high") + + after_stat = cli.stat() + self.assertEqual(cli.read_bytes(), before) + self.assertEqual( + (after_stat.st_ino, after_stat.st_mtime_ns), + (before_stat.st_ino, before_stat.st_mtime_ns), + ) + data = json.loads(before) + self.assertEqual(data["kirocrew.effortOwned"], {"claude-sonnet-4.6": "high"}) + self.assertEqual(data["kirocrew.effortOwnedStamp"], int(after_stat.st_mtime)) + + def test_a_level_change_writes_and_restamps(self): + from types import SimpleNamespace + + from kiro_crew import workspace_cli_settings + + clock = [1_790_000_000.0] + with tempfile.TemporaryDirectory() as tmp, unittest.mock.patch.object( + workspace_cli_settings, "time", SimpleNamespace(time=lambda: clock[0]) + ): + _write_effort_overlay(tmp, "claude-sonnet-4.6", "high") + cli = Path(tmp) / ".kiro" / "settings" / "cli.json" + before = json.loads(cli.read_text(encoding="utf-8")) + clock[0] += 10 + + _write_effort_overlay(tmp, "claude-sonnet-4.6", "low") + + after = json.loads(cli.read_text(encoding="utf-8")) + self.assertEqual( + after["chat.modelDefaults"]["claude-sonnet-4.6"]["output_config"]["effort"], + "low", + ) + self.assertEqual(after["kirocrew.effortOwned"], {"claude-sonnet-4.6": "low"}) + self.assertEqual(after["kirocrew.effortOwnedStamp"], int(cli.stat().st_mtime)) + self.assertEqual( + after["kirocrew.effortOwnedStamp"], before["kirocrew.effortOwnedStamp"] + 10 + ) + + def test_a_settings_link_leaving_the_work_dir_gets_no_write(self): + """A `.kiro` or `settings` link naming a folder outside the review + worker's work dir is refused by the settings lock every writer shares, + so the write is logged and skipped and the named folder gains nothing. + """ + from kiro_crew import platform_compat + + for redirect in (".kiro", "settings"): + with self.subTest(redirect=redirect), tempfile.TemporaryDirectory() as tmp: + work_dir = Path(tmp) / "work" + work_dir.mkdir() + outside = Path(tmp) / "outside" + (outside / "settings").mkdir(parents=True) + if redirect == ".kiro": + link, target = work_dir / ".kiro", outside + else: + (work_dir / ".kiro").mkdir() + link, target = work_dir / ".kiro" / "settings", outside / "settings" + try: + platform_compat.symlink_or_junction(target, link) + except (OSError, NotImplementedError): + self.skipTest("planting the link needs symlink or junction creation") + + with self.assertLogs(rp.logger, level="DEBUG") as logs: + _write_effort_overlay(str(work_dir), "claude-sonnet-4.6", "high") + + self.assertEqual([p.name for p in outside.rglob("*")], ["settings"]) + self.assertIn("could not write review effort overlay", "\n".join(logs.output)) + def test_a_planted_link_at_the_overlay_name_takes_no_bytes(self): """`work_dir` is the review worker's OWN cwd, so it is plantable. @@ -642,7 +765,14 @@ def test_a_planted_link_at_the_overlay_name_contributes_no_keys(self): published = json.loads(cli.read_text(encoding="utf-8")) self.assertNotIn("borrowed_key", published, "the aliased document's keys were republished") - self.assertEqual(list(published), ["chat.modelDefaults"]) + self.assertEqual( + list(published), + [ + "chat.modelDefaults", + "kirocrew.effortOwned", + "kirocrew.effortOwnedStamp", + ], + ) def test_reviewer_model_falls_back_to_default(self): self.assertEqual( diff --git a/src/kiro_crew/atomic_write.py b/src/kiro_crew/atomic_write.py index 681baa935cb..0bdc02e045f 100644 --- a/src/kiro_crew/atomic_write.py +++ b/src/kiro_crew/atomic_write.py @@ -1096,6 +1096,7 @@ def atomic_write( restrict_on_error: RestrictErrorPolicy = "raise", preserve_access_control_from: int | None = None, parent_dir_fd: int | None = None, + mtime_ns: int | None = None, ) -> None: """Write *content* to *path* atomically via unique temp file + rename. @@ -1172,6 +1173,11 @@ def atomic_write( read pinned to the inode the caller validated. The carry is ADDITIVE to ``mode=``, not a replacement. + *mtime_ns* sets the staged inode's access and modification times after its + content is complete (and synced when requested) but before the publishing + rename. Applying the timestamp through the open descriptor keeps a later + writer at the destination name from receiving this write's timestamp. + *parent_dir_fd* is an OPEN descriptor for the destination's directory, already pinned component-by-component by the caller (``pinned_fs`` supplies the walk). When given on a platform that can stage and rename through a @@ -1307,6 +1313,19 @@ def atomic_write( _carry_xattrs(fd, src_xattrs, path) if fsync: os.fsync(fd) + if mtime_ns is not None: + timestamp = (mtime_ns, mtime_ns) + if os.utime in os.supports_fd: + os.utime(fd, ns=timestamp) + elif pin is None: + os.utime(tmp, ns=timestamp) + elif os.utime in os.supports_dir_fd: + os.utime(os.path.basename(tmp), ns=timestamp, dir_fd=pin) + else: # pragma: no cover - no supported platform has this capability gap + raise NotImplementedError( + "stamping a descriptor-relative atomic write requires " + "descriptor- or dir_fd-capable os.utime" + ) # Close BEFORE the rename: on Windows os.replace cannot swap a file that # still has an open handle. Clear fd first so the except branch below # cannot double-close if this close is itself what fails. diff --git a/src/kiro_crew/dashboard/chat_api/slot_effort.py b/src/kiro_crew/dashboard/chat_api/slot_effort.py index d5fd241211c..e686cfc57c0 100644 --- a/src/kiro_crew/dashboard/chat_api/slot_effort.py +++ b/src/kiro_crew/dashboard/chat_api/slot_effort.py @@ -27,6 +27,7 @@ _slot_switch_session_lock, _subagents_attached_response, _switch_target_busy, + canonical_key, cap_effort_capability_levels, deny_app_slot_access, effective_session_key, @@ -114,6 +115,47 @@ async def api_chat_slot_selection_capabilities(request: web.Request) -> web.Resp ) +# An effort pick that is live and shown on the slot but whose explicit-Default +# intent could not be saved. The status reports the durable write; the slot and +# body report the value that is running. +_EFFORT_INTENT_NOT_SAVED = ( + "the effort choice is running but could not be saved; pick it again once the store is writable" +) +_EFFORT_INTENT_UNSAVED_CODE = "effort_intent_unsaved" +# A reset refusal whose rollback could not restore the earlier intent. +_EFFORT_INTENT_ROLLBACK_NOT_SAVED = "the earlier effort choice could not be saved again" + + +def _effort_intent_unsaved( + slot: _ChatSlot, + effort: str, + *, + normalized_model: str | None = None, + rebind_warning: str | None = None, +) -> web.Response: + """Report an intent write owed for a pick the live session already runs.""" + # Retry state belongs to the slot that received the pick. + slot._effort_intent_owed = True + return web.json_response( + { + "error": _EFFORT_INTENT_NOT_SAVED, + "code": _EFFORT_INTENT_UNSAVED_CODE, + "reasoning_effort": effort, + "model": normalized_model, + "warning": rebind_warning, + }, + status=503, + ) + + +def _bump_session_effort_intent_slots(state: DashboardState, session_key: str) -> None: + """Advance every open slot driving *session_key* after a saved intent change.""" + target = canonical_key(session_key) + for current_slot in list(state._slots.values()): + if canonical_key(effective_session_key(current_slot)) == target: + current_slot._session_effort_intent_gen += 1 + + async def api_chat_slot_reasoning_effort(request: web.Request) -> web.Response: """POST /api/chat/slots/{slot}/reasoning-effort — set reasoning effort. @@ -138,6 +180,7 @@ async def api_chat_slot_reasoning_effort(request: web.Request) -> web.Response: slot = state._slots.get(name) if not slot: return slot_not_found() + active_slot: _ChatSlot = slot denied = deny_app_slot_access(request.get("app", ""), slot, name, "slot_reasoning_effort") if denied is not None: return denied @@ -236,10 +279,10 @@ async def api_chat_slot_reasoning_effort(request: web.Request) -> web.Response: ) def normalize_legacy_model() -> dict[str, str]: - if not legacy_base or slot.model != legacy_model: # type: ignore[union-attr] + if not legacy_base or active_slot.model != legacy_model: return {} - slot.model = legacy_base # type: ignore[union-attr] - slot._dirty = True # type: ignore[union-attr] + active_slot.model = legacy_base + active_slot._dirty = True return {"model": legacy_base} if slot.reasoning_effort == effort: @@ -250,11 +293,147 @@ def normalize_legacy_model() -> dict[str, str]: # which the setter skips as a same-value write, so a codex pin still # carrying a legacy ``[level]`` suffix gets the bump too. slot._effort_pick_gen += 1 - if not legacy_base: + if not legacy_base and not slot._effort_intent_owed: return web.json_response({"ok": True, "reasoning_effort": effort}) logger.info("Slot %s reasoning_effort switched to %r", name, effort or "default") _updated_live: bool | None = False + # True once this request's own explicit clear has rewritten the overlay. + # A Default committed any other way (no live provider, the deferred push, + # a clear that raised, a model without effort) is carried to the key's + # next cold start, the first point that can remove an entry Kiro Crew + # did not write. + overlay_cleared = False + # Set once this request has saved the session key's intent and must not + # write it again: by the reset path before its reset await (a cold start + # during that await can spend it, and writing it again at the final + # commit would arm it a second time), or by a level's clear of a pending + # Default before its live push. + intent_recorded = False + + async def record_default_intent(key: str, pending: bool) -> bool | None: + """Save *key*'s explicit-Default intent. + + True once saved; False when it could not be saved. None when the + write is refused because a cold start under *key* is in flight: that + start read the key's effort basis once and publishes a session built + on that read, so a basis written meanwhile is one it cannot have + seen, and would leave the slot showing a value the session does not + run. Nothing is written and the caller answers a retryable 409. + Checked with no await before the write below, so no start can begin + between the check and the save. The write is counted + (``effort_intent_write``) from the in-memory flag through the save + and its put-back, so a start that takes the key's reservation during + the save waits for it to settle before reading the basis. + + While a cold start is in flight, a level pick on a key whose flag is + already clear has nothing to record: it is True at once, with nothing + written, counted or refused, because the start reads the same basis + either way. With no start in flight that pick saves the clear flag + like any other write, so a failed save is reported. While a cold + start is in flight, a Default pick on a key whose Default is already + pending stays refused: the start in flight may be on a level its + caller chose, which leaves that flag pending and brings the session + up on the level, so the pick has to reach that session once it is + registered, and the refusal is what makes the retry do that. A + caller waiting to claim the key's live session, which can wait a + whole turn, is not a cold start and refuses nothing here. + """ + if state.sessions.effort_basis_locked(key): + if not pending and not state.sessions.explicit_effort_default_pending(key): + return True + return None + with state.sessions.effort_intent_write(key): + prior = state.sessions.explicit_effort_default_pending(key) + if prior and not pending: + # This request can still put the flag back (a failed save + # below, the reset path's rollback), so its row stays counted + # until the request ends and no other key's pick can take it + # meanwhile. + _stack.enter_context(state.sessions.hold_explicit_effort_default(key)) + if not state.sessions.set_explicit_effort_default(key, pending): + return False + try: + await state.sessions.aflush() + except asyncio.CancelledError: + # A cancelled pick committed nothing, so its value must not + # reach the file with a later save. + state.sessions.set_explicit_effort_default(key, prior) + with contextlib.suppress(Exception): + await state.sessions.aflush() + raise + except Exception: + # Memory keeps what the file still holds. + state.sessions.set_explicit_effort_default(key, prior) + logger.warning("Could not save explicit effort Default intent", exc_info=True) + return False + if prior != pending: + _bump_session_effort_intent_slots(state, key) + return True + + def intent_unavailable() -> web.Response: + # Answered before the slot's value changed or anything reached the + # session it is bound to, so a retry starts over. + return web.json_response( + { + "error": "the effort choice could not be saved", + "code": "effort_intent_unavailable", + }, + status=503, + ) + + def start_in_flight() -> web.Response: + # A cold start under the key is in flight and has read, or will read, + # the effort basis this pick would change (``record_default_intent`` + # returned None; a pick that would leave it as it is passes). Nothing + # changed, so a retry once it has registered lands. The same code a + # live turn earns: the picker's failure copy for it already says to + # try again when the session is free. + return web.json_response( + {"error": "the session is starting", "code": "turn_in_flight"}, status=409 + ) + + def intent_refused(recorded: bool | None) -> web.Response: + """The refusal for an intent write that did not land before anything changed.""" + return start_in_flight() if recorded is None else intent_unavailable() + + async def put_back_default_unless_committed() -> None: + # Runs as this request exits: a pick that did not leave the slot on + # its level has not replaced the Default it cleared before the push. + # Written even while a cold start under the key is in flight: it + # restores a Default this same request cleared, so a start that read + # the cleared value arms nothing and the NEXT cold start applies the + # flag. The intent survives, one start late, and the hold taken at + # the clear is what keeps its row. + if active_slot.reasoning_effort == effort: + return + state.sessions.set_explicit_effort_default(session_key, True) + try: + await state.sessions.aflush() + except Exception: + # Memory keeps the Default, and the map's next save writes it. + logger.warning("Could not save the explicit effort Default again", exc_info=True) + + async def undo_live_push() -> bool: + """Put the live session this pick reached back on the slot's value. + + True once that session runs the value again. An automatic clear puts + back a Default, so an entry Kiro Crew did not write stays. + """ + if not isinstance(provider, AcpProvider): + return False + try: + if active_slot.reasoning_effort: + return bool(await provider.change_effort(active_slot.reasoning_effort)) + return bool(await provider.clear_effort()) + except Exception: + logger.warning("Could not put the live session back on its effort", exc_info=True) + return False + + # True once this request pushed its pick to the live session, which cannot + # be unwound: from then on a failed save keeps the pick and answers 503. + pushed_live = False + if isinstance(provider, AcpProvider) and provider.supports_effort(): # Guard against racing the in-flight prompt read loop: a live # change_effort issues session/set_config_option and its response wait @@ -266,8 +445,12 @@ def normalize_legacy_model() -> dict[str, str]: logger.info("Slot %s deferred live effort push: turn active", name) # This path's success point: the override is recorded on the # slot now and pushed to the live session next turn. + recorded = await record_default_intent(session_key, not effort) + if not recorded: + return intent_refused(recorded) slot.reasoning_effort = effort normalized = normalize_legacy_model() + slot._effort_intent_owed = False state.push_slots_update() return web.json_response( {"ok": True, "reasoning_effort": effort, "deferred": True, **normalized} @@ -275,11 +458,33 @@ def normalize_legacy_model() -> dict[str, str]: # change_effort handles both backends and persists the per-model # override + overlay. "" clears the override → fall back to model # default (kiro: /effort with model default; claude: leave as-is). + # This is the one explicit operator clear, so it alone may remove an + # overlay entry Kiro Crew did not write; every automatic clear (a + # live model switch on a Default slot) keeps the ownership guard. + # + # A level replaces a pending Default for good, so that clear is saved + # before the push: a push the file could not follow would leave the + # flag for a later cold start that takes no level, and that start + # would run Default under the level the slot shows. + if effort and state.sessions.explicit_effort_default_pending(session_key): + recorded = await record_default_intent(session_key, False) + if not recorded: + return intent_refused(recorded) + intent_recorded = True + _stack.push_async_callback(put_back_default_unless_committed) try: if effort: _updated_live = await provider.change_effort(effort) else: - _updated_live = await provider.clear_effort() + _updated_live = await provider.clear_effort( + owned_only=False, + fence_rewrite=state.sessions.fence_effort_overlay_rewrite, + ) + # A bool means this clear left nothing for a cold start to + # remove (False only asks for the reset below); None changed + # nothing and is refused below. + overlay_cleared = _updated_live is not None + pushed_live = bool(_updated_live) except Exception as exc: logger.warning( "change_effort(%s) failed for slot %s: %s: %s — falling back to reset", @@ -302,10 +507,18 @@ def normalize_legacy_model() -> dict[str, str]: # teardown, and the success path at the one before the final 200), # so either would show "default" while the overlay still holds the # old level and a respawn re-applies it. Commit nothing, reset - # nothing, and let the caller retry once the other writer is done. + # nothing, and report that the overlay is locked, a link or in a + # folder outside the work dir or on a sensitive path, unreadable, non-object + # or holding a non-object setting for this model, or past its read ceiling + # as read or once rewritten. return web.json_response( { - "error": "the workspace effort overlay is locked by another writer", + "error": ( + "the workspace effort overlay is locked or fenced by a spawning session, a link or " + "in a folder outside the work dir or on a sensitive path, unreadable, " + "not a JSON object, holds a non-object setting for this model, or exceeds " + "its 1 MiB size limit or would exceed it once rewritten" + ), "code": "effort_overlay_busy", }, status=409, @@ -324,16 +537,49 @@ def normalize_legacy_model() -> dict[str, str]: # happen and leave the slot value contradicting the persisted # override. Commit the slot value (it is what the new binding's # next cold start reads) and report the rebind as a warning. + # + # This request never touched the new binding's overlay, so a Default + # is carried to that key's next cold start, and a level clears any + # Default pending there. That is saved before the slot shows the + # pick: otherwise the slot would show a pick that key's next cold + # start does not run, and a retry of a Default would stop at the + # same-value check. A cold start in flight under the new binding + # refuses the write the same way a failed save does: it already read + # the basis this pick would change. + saved_new = await record_default_intent(effective_session_key(slot), not effort) + if not saved_new and (not pushed_live or await undo_live_push()): + # Nothing was pushed, or the session the push reached runs the + # slot's value again, so the refusal describes a pick that + # changed nothing. When it cannot be put back, the push stands + # and the pick is kept, answered 503 below. + return intent_refused(saved_new) slot.reasoning_effort = effort + # The push landed on the session the slot WAS bound to, so that key + # follows the ordinary rule, unless a level already cleared its + # Default before the push. + saved_old = intent_recorded or await record_default_intent( + session_key, not effort and not overlay_cleared + ) normalized = normalize_legacy_model() state.push_slots_update() + warning = ( + "slot session was rebound during the switch; " + "the new binding applies on its next cold start" + ) + if not (saved_old and saved_new): + return _effort_intent_unsaved( + slot, + effort, + normalized_model=normalized.get("model"), + rebind_warning=warning, + ) + slot._effort_intent_owed = False return web.json_response( { "ok": True, "reasoning_effort": effort, **normalized, - "warning": "slot session was rebound during the switch; " - "the new binding applies on its next cold start", + "warning": warning, } ) @@ -384,7 +630,24 @@ def normalize_legacy_model() -> dict[str, str]: # would make the acting tab keep the OLD store value for a # switch that actually happened. prior_effort = slot.reasoning_effort + prior_explicit_default = state.sessions.explicit_effort_default_pending(session_key) + recorded_default = not effort and not overlay_cleared + recorded = await record_default_intent(session_key, recorded_default) + if not recorded: + return intent_refused(recorded) + intent_recorded = True slot.reasoning_effort = effort + + async def restore_default_intent() -> bool: + # Undo only this request's own write: a cold start during the + # reset await may already have applied and spent it. One still + # in flight has read this request's write, so the undo is + # refused and reported as a rollback not saved. + current = state.sessions.explicit_effort_default_pending(session_key) + if current != recorded_default: + return True + return bool(await record_default_intent(session_key, prior_explicit_default)) + teardown_incomplete = False reset_ok = True try: @@ -417,6 +680,10 @@ def normalize_legacy_model() -> dict[str, str]: # then let the raise escape as a 500. if slot.reasoning_effort == effort: slot.reasoning_effort = prior_effort + if not await restore_default_intent(): + logger.warning( + "Could not restore explicit effort Default intent for %s", session_key + ) slot._dirty = True state.push_slots_update() raise @@ -434,6 +701,15 @@ def normalize_legacy_model() -> dict[str, str]: if isinstance(busy_provider, LLMProvider): if busy_provider.has_active_turn(): slot.reasoning_effort = prior_effort + if not await restore_default_intent(): + return web.json_response( + { + "error": "a turn is in flight", + "code": "turn_in_flight", + "warning": _EFFORT_INTENT_ROLLBACK_NOT_SAVED, + }, + status=409, + ) return web.json_response( {"error": "a turn is in flight", "code": "turn_in_flight"}, status=409, @@ -452,6 +728,11 @@ def normalize_legacy_model() -> dict[str, str]: except Exception: if slot.reasoning_effort == effort: slot.reasoning_effort = prior_effort + if not await restore_default_intent(): + logger.warning( + "Could not restore explicit effort Default intent for %s", + session_key, + ) slot._dirty = True state.push_slots_update() raise @@ -465,6 +746,15 @@ def normalize_legacy_model() -> dict[str, str]: and state.sessions.get_provider(session_key) is not None ): slot.reasoning_effort = prior_effort + if not await restore_default_intent(): + return web.json_response( + { + "error": "a turn is in flight", + "code": "turn_in_flight", + "warning": _EFFORT_INTENT_ROLLBACK_NOT_SAVED, + }, + status=409, + ) return web.json_response( {"error": "a turn is in flight", "code": "turn_in_flight"}, status=409, @@ -477,6 +767,15 @@ def normalize_legacy_model() -> dict[str, str]: # (that session was idle and already unbound); roll back and # let the retry resolve the current binding. slot.reasoning_effort = prior_effort + if not await restore_default_intent(): + return web.json_response( + { + "error": "slot session was rebound during the switch", + "code": "session_rebound", + "warning": _EFFORT_INTENT_ROLLBACK_NOT_SAVED, + }, + status=409, + ) return web.json_response( { "error": "slot session was rebound during the switch", @@ -492,6 +791,7 @@ def normalize_legacy_model() -> dict[str, str]: await _append_session_switch_notice(slot, "reasoning_effort", effort or "default") if teardown_incomplete: normalized = normalize_legacy_model() + slot._effort_intent_owed = False state.push_slots_update() return web.json_response( { @@ -503,7 +803,18 @@ def normalize_legacy_model() -> dict[str, str]: ) # Live-update and deferral paths commit here (the reset path already # committed before its reset, and assigning again is a no-op). + saved: bool | None = True + if not intent_recorded: + saved = await record_default_intent(session_key, not effort and not overlay_cleared) + if not saved and not pushed_live: + return intent_refused(saved) slot.reasoning_effort = effort normalized = normalize_legacy_model() + if not saved: + # The live session already runs the pick, so the slot keeps that value + # while the response reports the durable intent still owed. + state.push_slots_update() + return _effort_intent_unsaved(slot, effort, normalized_model=normalized.get("model")) + slot._effort_intent_owed = False state.push_slots_update() return web.json_response({"ok": True, "reasoning_effort": effort, **normalized}) diff --git a/src/kiro_crew/dashboard/chat_handlers.py b/src/kiro_crew/dashboard/chat_handlers.py index ae58f93ac36..fa4ded75f32 100644 --- a/src/kiro_crew/dashboard/chat_handlers.py +++ b/src/kiro_crew/dashboard/chat_handlers.py @@ -230,7 +230,12 @@ api_chat_slots_unrestored, ) from kiro_crew.dashboard.chat_api.slot_effort import ( # noqa: F401 + _EFFORT_INTENT_NOT_SAVED, + _EFFORT_INTENT_ROLLBACK_NOT_SAVED, + _EFFORT_INTENT_UNSAVED_CODE, + _bump_session_effort_intent_slots, _configured_backend_for_slot, + _effort_intent_unsaved, api_chat_slot_reasoning_effort, api_chat_slot_selection_capabilities, ) diff --git a/src/kiro_crew/dashboard/chat_runner.py b/src/kiro_crew/dashboard/chat_runner.py index b2cd48c2d89..de811e5fc06 100644 --- a/src/kiro_crew/dashboard/chat_runner.py +++ b/src/kiro_crew/dashboard/chat_runner.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import contextlib import difflib # noqa: F401 import functools # noqa: F401 import hashlib @@ -470,6 +471,57 @@ logger = logging.getLogger(__name__) + +async def _apply_pending_model_pick_at_turn_start( + state: Any, slot: Any, apply_pending_model_pick: Callable[[Any, Any], bool] +) -> bool: + """Commit a pending pick only when its explicit-Default clear is durable. + + A failed or cancelled save restores the pick, the slot's values and the flag; + this turn keeps its prior values and the next turn tries the pick again. + """ + if slot._pending_model_pick is None: + return False + session_key = effective_session_key(slot) + explicit_default_was_pending = state.sessions.explicit_effort_default_pending(session_key) + if not explicit_default_was_pending: + return apply_pending_model_pick(state, slot) + + # circular import: session_control imports this package's modules at module level. + from kiro_crew.dashboard.session_control import ( + capture_pending_model_pick_commit, + restore_pending_model_pick_commit, + ) + + snapshot = capture_pending_model_pick_commit(slot) + with ( + state.sessions.effort_intent_write(session_key), + state.sessions.hold_explicit_effort_default(session_key), + ): + reset_needed = apply_pending_model_pick(state, slot) + if state.sessions.explicit_effort_default_pending(session_key): + return reset_needed + try: + await state.sessions.aflush() + except asyncio.CancelledError: + restore_pending_model_pick_commit(slot, snapshot) + state.sessions.set_explicit_effort_default(session_key, True) + with contextlib.suppress(Exception): + await state.sessions.aflush() + raise + except Exception: + restore_pending_model_pick_commit(slot, snapshot) + state.sessions.set_explicit_effort_default(session_key, True) + logger.warning( + "Could not save the explicit effort Default clear for %s; " + "the pending pick waits for the next turn", + session_key, + exc_info=True, + ) + return False + return reset_needed + + #: Shown once when a live backend lost this chat's session and a fresh #: runtime re-loads it; the turn is retried once behind it. SESSION_NOT_FOUND_RETRY_TEXT = "⟳ The agent lost this chat's session — reconnecting…" diff --git a/src/kiro_crew/dashboard/chat_turn/eager_spawn.py b/src/kiro_crew/dashboard/chat_turn/eager_spawn.py index 9d7e4e0730a..d0344f22cef 100644 --- a/src/kiro_crew/dashboard/chat_turn/eager_spawn.py +++ b/src/kiro_crew/dashboard/chat_turn/eager_spawn.py @@ -22,6 +22,7 @@ from kiro_crew.dashboard.chat_runner import ( DashboardState, KiroCrewConfig, + SessionBusyError, SessionClosingError, SpeculativeResumeRefused, _arm_generation, @@ -36,6 +37,7 @@ resolve_session_agent_bindings, select_provider_backend, shutdown_event, + slot_switch_session_lock, subprocess_executor, ) from kiro_crew.dashboard.chat_turn.app_agent_binding import ( @@ -890,11 +892,25 @@ async def _spawn_admitted_prefetch( # which case the speculative session/load runs here and the # resumed=True observation is armed for the real turn. See # get_or_create's docstring. - # A slot or crew pin is the caller's own selection; with neither, the - # allocation resolves the default-model ladder itself, exactly as it - # will for the first real turn. - _requested_model = slot.model or agent_model or "" + # Effort switches save under this lock; it covers the effort read until the + # start's cold-start mark (then ``effort_basis_locked`` refuses a racing pick) + # and is handed off at gate entry so a queued start never stalls a first turn. + switch_lock = slot_switch_session_lock(session_key) + await switch_lock.acquire() + switch_lock_held = True + + def _hand_off_switch_lock(*_queue: object) -> None: + # Released at most once; the provider's start queues fire it again. + nonlocal switch_lock_held + if switch_lock_held: + switch_lock_held = False + switch_lock.release() + try: + if _slot_binding(slot) != _bound: + return + # A slot or crew pin is the caller's own pick; else the allocation resolves the ladder. + _requested_model = slot.model or agent_model or "" _, is_new, resumed = await sessions.get_or_create( session_key, agent=kiro_agent or slot.agent or None, @@ -909,10 +925,20 @@ async def _spawn_admitted_prefetch( speculative=True, speculative_resume=allow_resume, reasoning_effort_override=slot.reasoning_effort or None, + # A real turn can register while this prefetch is waiting for + # the switch lock. Never wait for its lease while holding the + # lock that its refusal fallback may need. + wait_if_busy=False, start_priority=start_priority, + on_gate_queued=_hand_off_switch_lock, **_app_allocation_kwargs(slot), ) - except (SpeculativeResumeRefused, SessionClosingError, SessionEndingError): + except ( + SpeculativeResumeRefused, + SessionBusyError, + SessionClosingError, + SessionEndingError, + ): # A refusal, a gateway shutdown, or a key being ended: no agent # start was attempted and failed, so none of them is counted. raise @@ -925,6 +951,13 @@ async def _spawn_admitted_prefetch( if not _is_pre_spawn_refusal(exc): _note_eager_spawn_failure(slot, exc) raise + finally: + # Every exit that never reached the gate: a binding that changed, + # a warm-pool claim, a refusal or failure ahead of the gate. + _hand_off_switch_lock() + except SessionBusyError: + logger.info("Eager spawn: %s left to active turn (busy)", session_key) + return except SpeculativeResumeRefused: # Two sources: the entry gate (resumable key, resume not # opted in — fresh eager spawn leaves it to the first turn) diff --git a/src/kiro_crew/dashboard/chat_turn/turn_admission.py b/src/kiro_crew/dashboard/chat_turn/turn_admission.py index b112d9166c9..616714ec9f8 100644 --- a/src/kiro_crew/dashboard/chat_turn/turn_admission.py +++ b/src/kiro_crew/dashboard/chat_turn/turn_admission.py @@ -32,6 +32,7 @@ Routing, SessionBusyError, _AppAgentNotLoaded, + _apply_pending_model_pick_at_turn_start, _MemoryUnavailable, capabilities_of, crew_log_emit, @@ -728,7 +729,7 @@ async def _turn_admit(turn: _ChatTurn) -> _Flow | None: # Warms the config the pick's fence re-check reads, so the # synchronous gate below does no file IO on the loop. await prewarm_enabled_check() - if apply_pending_model_pick(state, slot): + if await _apply_pending_model_pick_at_turn_start(state, slot, apply_pending_model_pick): arm_model_switch_reset(state, slot) # The commit above is memory-only; persist it before the provider is # acquired, so a gateway exit mid-turn does not restore the old pin. diff --git a/src/kiro_crew/dashboard/session_control.py b/src/kiro_crew/dashboard/session_control.py index a246f059a0e..cb4e10aa708 100644 --- a/src/kiro_crew/dashboard/session_control.py +++ b/src/kiro_crew/dashboard/session_control.py @@ -174,6 +174,7 @@ from kiro_crew.dashboard.session_control_parts.model_switch import ( # noqa: F401 _JEV_ROUTE_SPELLINGS, PendingModelPick, + PendingModelPickCommitSnapshot, _another_alias_is_mid_turn, _caller_tab_id, _pending_pick_view, @@ -183,9 +184,11 @@ apply_deferred_model_switch, apply_pending_model_pick, arm_model_switch_reset, + capture_pending_model_pick_commit, effort_commit_needs_reset, legacy_effort_base, model_switch_deferred, + restore_pending_model_pick_commit, set_model_target, ) from kiro_crew.dashboard.session_control_parts.session_create import ( # noqa: F401 diff --git a/src/kiro_crew/dashboard/session_control_parts/model_switch.py b/src/kiro_crew/dashboard/session_control_parts/model_switch.py index 16c62a9da1d..74d6f56dac5 100644 --- a/src/kiro_crew/dashboard/session_control_parts/model_switch.py +++ b/src/kiro_crew/dashboard/session_control_parts/model_switch.py @@ -262,6 +262,7 @@ async def set_model_target( pick_gen=slot._model_pick_gen, reasoning_effort=reasoning_effort, effort_gen=slot._effort_pick_gen, + effort_intent_gen=slot._session_effort_intent_gen, pair_id_backend=backend in ACP_BACKENDS_MODEL_EFFORT_PAIR_IDS, ) @@ -310,10 +311,15 @@ class PendingModelPick: setting alone. ``effort_gen`` is the target's effort write generation at call time: any write since (the effort dropdown, a fork, a restore) means a newer choice landed, and it wins over this one even when it returned to - the level the caller saw. ``pair_id_backend`` records that the target's - backend spells effort into the model id (``gpt-6-astra[max]``), so a - committed level must also fold a legacy suffix off the pin, as the effort - dropdown route does. + the level the caller saw. ``effort_intent_gen`` is the target slot's + explicit-Default intent generation at call time. A later effort-picker + choice on any slot driving the same session bumps every such slot when it + changes the session's explicit-Default flag (a Default pick, or a level + pick that clears a pending Default) and supersedes this pick's effort half. + A level pick with no Default pending does not. ``pair_id_backend`` records + that the target's backend spells effort into the model id + (``gpt-6-astra[max]``), so a committed level must also fold a legacy suffix + off the pin, as the effort dropdown route does. """ model: str | None @@ -323,6 +329,7 @@ class PendingModelPick: pick_gen: int reasoning_effort: str | None = None effort_gen: int = 0 + effort_intent_gen: int = 0 pair_id_backend: bool = False @@ -634,6 +641,51 @@ async def apply_deferred_model_switch( return True +@dataclass(frozen=True) +class PendingModelPickCommitSnapshot: + """The slot state a pending-pick commit can change.""" + + pending_model_pick: PendingModelPick + model: str + jev_route: bool + reasoning_effort: str + model_pick_gen: int + effort_pick_gen: int + + +def capture_pending_model_pick_commit(slot: "_ChatSlot") -> PendingModelPickCommitSnapshot: + """Capture the exact slot state a pending-pick commit can change.""" + pick = slot._pending_model_pick + assert pick is not None + return PendingModelPickCommitSnapshot( + pending_model_pick=pick, + model=slot.model, + jev_route=slot.jev_route, + reasoning_effort=slot._reasoning_effort, + model_pick_gen=slot._model_pick_gen, + effort_pick_gen=slot._effort_pick_gen, + ) + + +def restore_pending_model_pick_commit( + slot: "_ChatSlot", snapshot: PendingModelPickCommitSnapshot +) -> None: + """Restore a deferred commit without advancing either pick generation.""" + slot._pending_model_pick = snapshot.pending_model_pick + slot.model = snapshot.model + slot.jev_route = snapshot.jev_route + slot._reasoning_effort = snapshot.reasoning_effort + slot._model_pick_gen = snapshot.model_pick_gen + slot._effort_pick_gen = snapshot.effort_pick_gen + _audit( + caller_session_key=snapshot.pending_model_pick.caller_session_key, + operation="set_model", + slot_key=slot.key, + outcome="deferred", + detail={"code": "effort_default_clear_unsaved", "stage": "turn_start"}, + ) + + def apply_pending_model_pick(state: "DashboardState", slot: "_ChatSlot") -> bool: """Commit *slot*'s pending ``session_set_model`` pick, if it is still allowed. @@ -642,9 +694,16 @@ def apply_pending_model_pick(state: "DashboardState", slot: "_ChatSlot") -> bool SYNCHRONOUS on purpose: the gate and the writes to ``slot.model`` and ``slot.reasoning_effort`` run with no suspension between them, so nothing can link or mirror the target after it was authorized and before either - changed. A pick the gate now refuses is dropped and audited, and the turn - runs on the model and effort it already had. So is each half of a pick the - user has overtaken with a newer choice from that half's own control. + changed. An effort commit also clears the key's pending explicit Default in + that same step. A failed save of that clear restores the pick, the slot's + values and the flag, so this turn runs on its prior values and the next turn + tries the pick again. A pick the gate now refuses is dropped and audited, + and the turn runs on the model and effort it already had. So is each half + of a pick the user has overtaken with a newer choice from that half's own + control; for effort, a later effort-picker choice on any slot driving the + same session is newer only when it changes the session's explicit-Default + flag: a Default pick, or a level pick that clears a pending Default. A + level pick with no Default pending does not supersede the call. The ownership fence only tightens: a caller fenced at call time stays fenced, and one that was not is re-checked now, since it may have become a @@ -684,6 +743,7 @@ def apply_pending_model_pick(state: "DashboardState", slot: "_ChatSlot") -> bool # newer model pick supersedes the effort half as well. if effort is not None and ( slot._effort_pick_gen != pick.effort_gen + or slot._session_effort_intent_gen != pick.effort_intent_gen or (pick.pair_id_backend and slot._model_pick_gen != pick.pick_gen) ): effort = None @@ -767,6 +827,7 @@ def apply_pending_model_pick(state: "DashboardState", slot: "_ChatSlot") -> bool # that takes no level keeps it stored only, as the dropdown does. changed = True slot.reasoning_effort = effort + state.sessions.set_explicit_effort_default(effective_session_key(slot), False) _audit( caller_session_key=pick.caller_session_key, operation="set_model", diff --git a/src/kiro_crew/dashboard/state_parts/chat_slot.py b/src/kiro_crew/dashboard/state_parts/chat_slot.py index 2fb574d682e..1a4090d1ff9 100644 --- a/src/kiro_crew/dashboard/state_parts/chat_slot.py +++ b/src/kiro_crew/dashboard/state_parts/chat_slot.py @@ -207,6 +207,8 @@ class _ChatSlot: "_transient_5xx_retries", "_transient_ladder_started", "_turn_retries", + "_effort_intent_owed", + "_session_effort_intent_gen", "_infra_retries", "_fallback_candidate_idx", "_fallback_walked", @@ -537,6 +539,8 @@ class _ChatSlot: _transient_5xx_retries: int _transient_ladder_started: float _turn_retries: dict[str, int] + _effort_intent_owed: bool + _session_effort_intent_gen: int _infra_retries: int _fallback_candidate_idx: int _fallback_walked: list[str] diff --git a/src/kiro_crew/dashboard/state_parts/slot_ladder.py b/src/kiro_crew/dashboard/state_parts/slot_ladder.py index 351417c4afb..b93de42a5a7 100644 --- a/src/kiro_crew/dashboard/state_parts/slot_ladder.py +++ b/src/kiro_crew/dashboard/state_parts/slot_ladder.py @@ -9,7 +9,7 @@ Writes: ``_acp_pipe_death_retries``, ``_active_fallback_model``, ``_batch_rejected``, ``_batch_rejected_cause``, ``_carried_ttft_clock``, ``_compaction_continue_retries``, ``_compaction_fail_cooldown_until``, ``_compaction_fail_streak``, -``_compaction_failed_retries``, ``_empty_episode_productive``, +``_compaction_failed_retries``, ``_effort_intent_owed``, ``_empty_episode_productive``, ``_empty_response_retries``, ``_end_wait_by``, ``_end_wait_request``, ``_fallback_candidate_idx``, ``_fallback_client_pick_epoch``, ``_fallback_pick_gen``, ``_fallback_primary_model``, ``_fallback_slot_model``, ``_fallback_walked``, @@ -22,7 +22,7 @@ ``_prestream_exhausted_cycles``, ``_promise_only_retries``, ``_prompt_busy_retries``, ``_refusal_client_pick_epoch``, ``_refusal_fallback_attempted``, ``_refusal_fallback_candidate``, ``_refusal_fallback_primary``, -``_refusal_fallback_session_key``, ``_refusal_pick_gen``, +``_refusal_fallback_session_key``, ``_refusal_pick_gen``, ``_session_effort_intent_gen``, ``_session_not_found_retry_used``, ``_stale_recovery_exhausted_emitted``, ``_stale_recovery_retries``, ``_steer_act_now``, ``_steer_admissions``, ``_steer_attachment_meta``, ``_steer_audience_fence_holders``, @@ -121,6 +121,15 @@ def init_ladder_fields(self: _ChatSlot) -> None: # not. Read by the landed turn's footer stats and recovery telemetry; # emptied when a top-level turn that is not a recovery starts. self._turn_retries = {} + # Set while this chat's pick is live but its explicit-Default intent was + # not saved, so the same pick again is a retry rather than a no-op; it is + # never persisted and goes with the slot. + self._effort_intent_owed = False + # Bumped on every open slot of this slot's session when the effort + # picker saves a change to the session's explicit-Default flag, so a + # pending session_set_model effort can tell a newer choice landed. Other + # writes of the flag do not bump it. Never persisted; goes with the slot. + self._session_effort_intent_gen = 0 # L1 gateway-capacity retries: how many times THIS cycle waited out an # infrastructure refusal of a tool call (the ladder owns the budget; this # is the slot-visible count the health panel classifies as recovering). diff --git a/src/kiro_crew/providers/acp.py b/src/kiro_crew/providers/acp.py index 5fef29855be..919ba41161a 100644 --- a/src/kiro_crew/providers/acp.py +++ b/src/kiro_crew/providers/acp.py @@ -7,14 +7,17 @@ import importlib as _importlib import json # noqa: F401 import logging +import os # noqa: F401 import sys as _sys import time # noqa: F401 from collections.abc import AsyncIterator, Callable # noqa: F401 -from contextlib import aclosing # noqa: F401 +from contextlib import AbstractContextManager, aclosing, nullcontext # noqa: F401 from pathlib import Path from typing import TYPE_CHECKING, Any, AsyncContextManager -from kiro_crew import _facade_mirror, model_scope # noqa: F401 +from kiro_crew import model_scope # noqa: F401 +from kiro_crew import _facade_mirror +from kiro_crew import atomic_write as atomic_write_module # noqa: F401 from kiro_crew.acp.client import ( # noqa: F401 DEFAULT_MODEL, AcpAuthRequired, @@ -103,6 +106,7 @@ model_supports_effort, resolve_effort_for_model, ) +from kiro_crew.hooks import safe_read_file_bytes_nolink # noqa: F401 from kiro_crew.mcp_hot_reload import mcp_hot_reload_supported, parse_kiro_cli_version from kiro_crew.messaging.link import telemetry_channel_of # noqa: F401 from kiro_crew.platform_compat import pid_exists # noqa: F401 @@ -111,7 +115,6 @@ from kiro_crew.providers import acp_model_effort as _acp_model_effort from kiro_crew.providers import acp_runtime_start as _acp_runtime_start from kiro_crew.providers import acp_turns as _acp_turns -from kiro_crew.providers.acp_cli_overlay import _read_cli_overlay from kiro_crew.providers.base import ( # noqa: F401 CancelOutcome, LLMEvent, @@ -126,6 +129,8 @@ from kiro_crew.workspace_cli_settings import ( # noqa: F401 CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS, CLI_SETTINGS_LOCK_TIMEOUT_SECS, + locked_workspace_cli_settings, + workspace_cli_settings_fence_key, workspace_cli_settings_lock, ) @@ -271,6 +276,12 @@ def __init__( self._effort_defaults = effort_defaults # The spelling the factory keyed ``effort_per_model`` under. self._configured_model: str = model or "" + self._explicit_effort_default_pending = False + self._explicit_effort_default_applied = False + self._explicit_effort_default_projected = False + self._fence_explicit_effort_default_rewrite: Callable[ + [], AbstractContextManager[object] + ] = nullcontext # MCP Tool Search toggle (kiro-cli backend only). None = caller did not # specify (e.g. claude_code factory), so leave the overlay untouched. # True/False = write the kiro settings overlay deterministically so the @@ -286,26 +297,6 @@ def __init__( if tool_search_min_tokens is None else tool_search_min_tokens ) - if self._client.backend in ACP_BACKENDS_EFFORT_OVERLAY: - # Read back the levels the overlay already holds, so after a gateway - # restart the map names what kiro-cli will run. Caller-provided - # overrides win — only fill gaps. - # - # The overlay's own readers only. The file is keyed by work dir, so - # every session in one project shares it; read back by a harness that - # never opens it (KAS), it would start a new chat at whatever level - # another chat in the same work dir picked last. Such a harness takes - # its level from the factory alone. - try: - for m, lvl in _read_cli_overlay(self._client._work_dir).items(): - self._effort_per_model.setdefault(m, lvl) - except Exception: - logger.debug("ACP effort overlay recovery failed", exc_info=True) - if self._client.backend in ACP_BACKENDS_KIRO_FAMILY: - # Write the overlay BEFORE the first spawn so kiro-cli reads it on - # session/new. Each writer is scoped to the harnesses that read its keys. - self._apply_effort_overlay() - self._apply_tool_search_overlay() @property def client(self) -> AcpClient: @@ -948,11 +939,17 @@ async def new_conversation(self) -> None: ``start()``), which issues a fresh ``session/new`` on the already-running process, skipping subprocess spawn + the ``initialize`` handshake. This is the primitive a warm session pool uses to reuse one worker across - independent tasks without leaking context between them. The claude backend - would delegate to its own client-side reset the same way (its ``AcpClient`` - does not ship one — hence the type ignore). + independent tasks without leaking context between them. After the fresh + session exists, it binds a level requested with nothing pinned to the + model that session serves, because a changed backend default can make + it a different model, then reasserts a resolved kiro effort because the warm + process may have read an operator or peer projection at its own spawn. + The claude backend would delegate to its own client-side reset the same + way (its ``AcpClient`` does not ship one — hence the type ignore). """ await self._client.new_conversation() # type: ignore[attr-defined] + await self._bind_unbound_effort() + await self._reassert_projected_effort() def is_alive(self) -> bool: """Whether the shared client is responsive. @@ -1062,10 +1059,20 @@ def session_id(self) -> str: # The workspace ``cli.json`` overlay a spawn reads: ``kiro_crew.providers.acp_cli_overlay``. _apply_effort_overlay = _acp_cli_overlay._apply_effort_overlay + _effort_overlay_fence_mode = _acp_cli_overlay._effort_overlay_fence_mode + _log_effort_overlay_projection_skipped = _acp_cli_overlay._log_effort_overlay_projection_skipped + _project_explicit_effort_default = _acp_cli_overlay._project_explicit_effort_default + _spawn_effort_model = property(_acp_cli_overlay._spawn_effort_model) + _write_effort_overlay = _acp_cli_overlay._write_effort_overlay + arm_explicit_effort_default = _acp_cli_overlay.arm_explicit_effort_default + explicit_effort_default_applied = property( + _acp_cli_overlay.explicit_effort_default_applied + ) # type: ignore[assignment] _apply_tool_search_overlay = _acp_cli_overlay._apply_tool_search_overlay # The model and reasoning effort the session runs at: ``kiro_crew.providers.acp_model_effort``. maybe_refresh_available_models = _acp_model_effort.maybe_refresh_available_models _bind_unbound_effort = _acp_model_effort._bind_unbound_effort + _reassert_projected_effort = _acp_model_effort._reassert_projected_effort _advertised_effort_levels = _acp_model_effort._advertised_effort_levels accepts_effort_level = _acp_model_effort.accepts_effort_level supports_effort = _acp_model_effort.supports_effort @@ -1177,17 +1184,49 @@ def provider_label(provider: Any) -> str: #: Module-level names this module bound before the split that only owner code reads. _SERVED: frozenset[str] = frozenset( { + "_BoundedCliEffortPairs", "_CLI_JSON_ENCODING", + "_CLI_JSON_MAX_BYTES", + "_CliJsonVersion", + "_EFFORT_OVERLAY_AUTO_CLEAR_WARNING", + "_EFFORT_OVERLAY_DEFAULT_CLEAR_WARNING", + "_EFFORT_OVERLAY_MODEL_OVERRIDE", + "_KIROCREW_EFFORT_OWNED_KEY", + "_KIROCREW_EFFORT_OWNED_STAMP_KEY", + "_OVERLAY_REFUSED", + "_OverlayParentNotAnObject", + "_OverlayTooLarge", "_RESUME_BACKOFF_BASE_S", "_RESUME_MAX_ATTEMPTS", "_RESUME_TRANSIENT_LOCK_MARKERS", "_SETUP_FAILURE_LOG_CAP", + "_UNKNOWN_CLI_JSON_VERSION", + "_WARN_UNOWNED_MAX_CHARS", + "_WARN_UNOWNED_MAX_ITEMS", + "_WORKSPACE_EFFORT_FENCES", + "_WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS", + "_WorkspaceEffortOverlayFence", "_ZERO_TOOL_CHECK_TIMEOUT_SECS", + "_await_overlay_write", "_clamp_min_pct", "_clamp_min_tokens", "_clear_cli_overlay_effort", + "_cli_effort_is_owned", + "_cli_json_version", + "_current_cli_json_version", + "_hold_workspace_effort_overlay_fence", "_is_transient_resume_lock_error", + "_log_atom", + "_log_effort_level", + "_log_model_key", + "_owned_cli_effort", + "_pinned_settings_fd", + "_publish_cli_overlay", + "_read_cli_overlay_document", + "_read_pinned_cli_json", "_unlink_dead_session_lock", + "_warn_unowned_cli_effort", + "_workspace_effort_overlay_fence", "_write_cli_overlay", "_write_tool_search_overlay", } @@ -1206,9 +1245,41 @@ def provider_label(provider: Any) -> str: if TYPE_CHECKING: # every owner-defined name in _EXPORTS, for type checkers and IDEs from kiro_crew.providers.acp_cli_overlay import ( # noqa: F401 _CLI_JSON_ENCODING, + _CLI_JSON_MAX_BYTES, + _EFFORT_OVERLAY_AUTO_CLEAR_WARNING, + _EFFORT_OVERLAY_DEFAULT_CLEAR_WARNING, + _EFFORT_OVERLAY_MODEL_OVERRIDE, + _KIROCREW_EFFORT_OWNED_KEY, + _KIROCREW_EFFORT_OWNED_STAMP_KEY, + _OVERLAY_REFUSED, + _UNKNOWN_CLI_JSON_VERSION, + _WARN_UNOWNED_MAX_CHARS, + _WARN_UNOWNED_MAX_ITEMS, + _WORKSPACE_EFFORT_FENCES, + _WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS, + _await_overlay_write, + _BoundedCliEffortPairs, _clamp_min_pct, _clamp_min_tokens, _clear_cli_overlay_effort, + _cli_effort_is_owned, + _cli_json_version, + _CliJsonVersion, + _current_cli_json_version, + _hold_workspace_effort_overlay_fence, + _log_atom, + _log_effort_level, + _log_model_key, + _OverlayParentNotAnObject, + _OverlayTooLarge, + _owned_cli_effort, + _pinned_settings_fd, + _publish_cli_overlay, + _read_cli_overlay_document, + _read_pinned_cli_json, + _warn_unowned_cli_effort, + _workspace_effort_overlay_fence, + _WorkspaceEffortOverlayFence, _write_cli_overlay, _write_tool_search_overlay, ) diff --git a/src/kiro_crew/providers/acp_cli_overlay.py b/src/kiro_crew/providers/acp_cli_overlay.py index b40c3ec059a..085ef00716f 100644 --- a/src/kiro_crew/providers/acp_cli_overlay.py +++ b/src/kiro_crew/providers/acp_cli_overlay.py @@ -8,17 +8,36 @@ class binds it by name, so it reads and writes the session's own state on the instance, and a patch of the method on the class or on an instance reaches every caller. +Instance fields written: ``_explicit_effort_default_applied``, +``_explicit_effort_default_pending``, ``_explicit_effort_default_projected``, +``_fence_explicit_effort_default_rewrite``. + Instance fields read: ``_client``, ``_tool_search``, ``_tool_search_min_pct``, ``_tool_search_min_tokens``. """ from __future__ import annotations +import asyncio +import copy import json import logging +import os +import weakref +from collections.abc import AsyncIterator, Callable +from contextlib import ( + AbstractAsyncContextManager, + AbstractContextManager, + asynccontextmanager, + suppress, +) +from contextvars import ContextVar from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal +from kiro_crew import platform_compat +from kiro_crew.acp._dispatch import redact_backend_text +from kiro_crew.acp.client import DEFAULT_MODEL from kiro_crew.acp.types import ACP_BACKENDS_EFFORT_OVERLAY, ACP_BACKENDS_TOOL_SEARCH_OVERLAY from kiro_crew.agent_sdk.tool_search import ( TOOL_SEARCH_DEFAULT_MIN_PCT, @@ -27,11 +46,25 @@ class binds it by name, so it reads and writes the session's own state on the in clamp_min_tokens, ) from kiro_crew.atomic_write import atomic_write -from kiro_crew.effort import effort_settings_key +from kiro_crew.effort import EFFORT_LEVELS, effort_settings_key +from kiro_crew.hooks import FileTooLargeError, safe_read_file_bytes_nolink from kiro_crew.workspace_cli_settings import ( + CLI_JSON_REFUSED, CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS, CLI_SETTINGS_LOCK_TIMEOUT_SECS, - workspace_cli_settings_lock, + EFFORT_OWNED_KEY, + EFFORT_OWNED_STAMP_KEY, + LockedCliSettings, + admitted_workspace_cli_settings_dir, + effort_ownership_stamp_matches, + locked_workspace_cli_settings, + owned_document_unchanged, + pinned_cli_settings_fd, + publish_locked_cli_settings, + read_pinned_cli_json, + settings_json_text, + stamp_effort_ownership, + workspace_cli_settings_fence_key, ) if TYPE_CHECKING: @@ -40,87 +73,426 @@ class binds it by name, so it reads and writes the session's own state on the in #: The provider's own logger: this code logged under that name before the split. logger = logging.getLogger("kiro_crew.providers.acp") - #: Decoding for the workspace ``cli.json``. The file can be a person's own #: workspace settings, and Windows editors save UTF-8 with a byte-order mark, -#: which ``json.loads`` refuses on decoded text. A refused file reads as empty -#: here, and the overlay writers then replace it with only Kiro Crew's keys, so -#: every reader drops one leading mark. Writers emit plain UTF-8. +#: which ``json.loads`` refuses on decoded text. A file that does not parse is +#: left unchanged and the overlay write fails, so the reader drops one leading +#: mark and the overlay still applies. Writers emit plain UTF-8. _CLI_JSON_ENCODING = "utf-8-sig" +_WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS = ( + CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS + CLI_SETTINGS_LOCK_TIMEOUT_SECS +) +# The key names live in ``workspace_cli_settings`` so every writer of the file, +# including the skill projection, carries the record under one rule. +_KIROCREW_EFFORT_OWNED_KEY = EFFORT_OWNED_KEY +_KIROCREW_EFFORT_OWNED_STAMP_KEY = EFFORT_OWNED_STAMP_KEY +_WARN_UNOWNED_MAX_ITEMS = 10 +_WARN_UNOWNED_MAX_CHARS = 120 +# A settings file is kilobytes; past this Kiro Crew does not read it, so every +# structure derived from it stays bounded. +_CLI_JSON_MAX_BYTES = 1024 * 1024 +# What the reader says about a shape it refuses; defined in ``workspace_cli_settings`` +# so the effort overlay and the skill projection report it in the same words. +_OVERLAY_REFUSED = CLI_JSON_REFUSED +_CliJsonVersion = tuple[int, int, int, int, int] +_UNKNOWN_CLI_JSON_VERSION = object() + + +class _OverlayTooLarge(ValueError): + """Raised when a workspace cli.json overlay exceeds the read ceiling. + + The ceiling applies to the file as read and to the bytes a rewrite would publish. + """ + + +class _OverlayParentNotAnObject(ValueError): + """Raised when an explicit Default would replace a non-object overlay parent.""" + + +def _log_atom(value: object) -> str: + """Render a cli.json value as one bounded, single-line log token.""" + if isinstance(value, str): + rendered = json.dumps(value[:_WARN_UNOWNED_MAX_CHARS], ensure_ascii=True) + elif isinstance(value, (int, float, bool)) or value is None: + rendered = json.dumps(value, ensure_ascii=True) + elif isinstance(value, dict): + rendered = f"" + elif isinstance(value, list): + rendered = f"" + else: + rendered = f"<{type(value).__name__}>" + if len(rendered) > _WARN_UNOWNED_MAX_CHARS: + return rendered[: _WARN_UNOWNED_MAX_CHARS - 4] + '..."' + return rendered + + +def _log_effort_level(value: object) -> str: + """Render a cli.json effort leaf without exposing non-level file content.""" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=True) if value in EFFORT_LEVELS else "" + if isinstance(value, dict): + return f"" + if isinstance(value, list): + return f"" + return f"<{type(value).__name__}>" + + +def _log_model_key(value: object) -> str: + """Render a cli.json model key after redacting the whole value before bounding it.""" + if not isinstance(value, str): + return _log_atom(value) + rendered = json.dumps(redact_backend_text(value), ensure_ascii=True) + if len(rendered) > _WARN_UNOWNED_MAX_CHARS: + return rendered[: _WARN_UNOWNED_MAX_CHARS - 4] + '..."' + return rendered + + +def _owned_cli_effort(settings: dict[str, Any], file_mtime: int | None) -> dict[str, str]: + """Return effort provenance only when it belongs to this file version.""" + if not effort_ownership_stamp_matches(settings, file_mtime): + return {} + record = settings.get(_KIROCREW_EFFORT_OWNED_KEY) + if not isinstance(record, dict): + return {} + return { + model: level + for model, level in record.items() + if isinstance(model, str) and model and isinstance(level, str) and level + } + + +def _cli_effort_is_owned(active: object, recorded: str | None) -> bool: + """Return whether a non-empty record names the identical active effort value.""" + return isinstance(recorded, str) and bool(recorded) and active == recorded + + +def _pinned_settings_fd(settings: LockedCliSettings) -> int | None: + """The lock's settings descriptor when both the read and the publish can go through it.""" + return pinned_cli_settings_fd(settings) + + +def _cli_json_version(metadata: os.stat_result) -> _CliJsonVersion: + """Return the fields that identify one observed version of ``cli.json``.""" + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + + +def _current_cli_json_version( + settings: LockedCliSettings, settings_fd: int | None +) -> _CliJsonVersion | None: + """Stat the current ``cli.json`` name through the writer's selected path.""" + try: + if settings_fd is None: + metadata = os.stat(settings.cli_json, follow_symlinks=False) + else: + metadata = os.stat( + "cli.json", + dir_fd=settings_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + return None + except OSError as exc: + raise OSError( + f"workspace cli.json overlay {settings.cli_json} changed while the overlay " + "was written; left unchanged" + ) from exc + return _cli_json_version(metadata) + + +def _publish_cli_overlay( + settings: LockedCliSettings, + document: dict[str, Any], + read_document: dict[str, Any], + file_mtime: int | None, + read_version: _CliJsonVersion | None | object, +) -> None: + """Publish one overlay into the settings directory the lock opened, where supported. + + *read_document* is a copy of the document :func:`_read_cli_overlay_document` + returned under this lock, and *file_mtime* the mtime it returned with it. A + document that equals the file read, apart from the stamp, while that file's + record is valid is not written: the file keeps its bytes and its mtime, so the + record stays valid and a same-level projection before every spawn leaves the + file alone. + + A settings directory replaced or linked by name after the lock cannot receive the + write. A ``cli.json`` changed after the read is left unchanged and the writer raises. + A publish that would leave the file unchanged also refuses either change. A + document whose bytes would exceed the read ceiling raises + :class:`_OverlayTooLarge` before anything is written. + A writer that changes the file after the version check can still race the rename; + closing that window requires every writer to take the shared lock. Platforms without + descriptor-relative writes use the by-name write floor. + """ + settings_fd = _pinned_settings_fd(settings) + if settings_fd is not None and not settings.holds_named_settings_dir(): + raise OSError("workspace CLI settings directory changed before the overlay was written") + current_version = _current_cli_json_version(settings, settings_fd) + if read_version is _UNKNOWN_CLI_JSON_VERSION or current_version != read_version: + raise OSError( + f"workspace cli.json overlay {settings.cli_json} changed while the overlay " + "was written; left unchanged" + ) + if owned_document_unchanged(document, read_document, file_mtime): + return + # The timestamp is applied to the staged inode before publication, so the + # ownership record and the inode carrying it become visible together. A + # document with no record carries no stamp and takes a natural mtime. + stamp_ns = stamp_effort_ownership(document) + content = settings_json_text(document).encode("utf-8") + if len(content) > _CLI_JSON_MAX_BYTES: + raise _OverlayTooLarge( + f"workspace cli.json overlay {settings.cli_json} would exceed the " + f"{_CLI_JSON_MAX_BYTES}-byte ceiling; left unchanged" + ) + publish_locked_cli_settings( + settings, + content, + write=atomic_write, + mtime_ns=stamp_ns, + check_named_before=False, + ) + + +def _read_pinned_cli_json( + settings_fd: int, cli_json: Path +) -> tuple[bytes, os.stat_result, os.stat_result] | None: + """Read ``cli.json`` through the shared pinned settings reader.""" + return read_pinned_cli_json(settings_fd, cli_json, max_bytes=_CLI_JSON_MAX_BYTES) + + +def _read_cli_overlay_document( + settings: LockedCliSettings, +) -> tuple[dict[str, Any], int | None, _CliJsonVersion | None | object]: + """Return the settings object an overlay writer merges into. + + The returned version is the file's metadata from before its bytes were read, so + the guarded publish refuses after any later change, even one during the read. A + missing file is an empty document. An existing file that cannot be read, + is a link, is hardlinked, is non-regular, or escapes the workspace settings + directory raises so the writer leaves its bytes untouched. An overlay past + the ceiling is left unchanged and the write raises, as an unparseable one does. + Where descriptor-relative writes are supported, the read and publish use + the settings directory the lock opened, so a folder replaced or linked after + the lock cannot receive the write and the writer raises. Elsewhere both go by + name; on Windows the lock holds ``.kiro`` and ``settings`` open meanwhile, so + neither can be renamed or deleted until the writer is done. + """ + cli_json = settings.cli_json + settings_fd = _pinned_settings_fd(settings) + raw: bytes | None + file_mtime: int | None = None + file_version: _CliJsonVersion | None | object + if settings_fd is not None: + read_result = _read_pinned_cli_json(settings_fd, cli_json) + if read_result is None: + return {}, None, None + raw, before_read, after_read = read_result + file_mtime = int(after_read.st_mtime) + file_version = _cli_json_version(before_read) + if len(raw) > _CLI_JSON_MAX_BYTES: + raise _OverlayTooLarge( + f"workspace cli.json overlay {cli_json} is past the " + f"{_CLI_JSON_MAX_BYTES}-byte ceiling; left unchanged" + ) + else: + if not os.path.lexists(cli_json): + return {}, None, None + if platform_compat.is_link_or_junction(cli_json): + raise OSError(f"workspace cli.json overlay {cli_json} is a link; left unchanged") + + # The lock's folder is the one ``.kiro/settings`` resolved to inside the work dir, + # so an accepted link reads and writes the same folder. + settings_root = str(settings.settings_dir) + try: + file_version = _cli_json_version(os.stat(cli_json, follow_symlinks=False)) + except OSError: + file_version = _UNKNOWN_CLI_JSON_VERSION + try: + raw = safe_read_file_bytes_nolink( + str(cli_json), + within_root=settings_root, + max_bytes=_CLI_JSON_MAX_BYTES, + within_root_is_canonical=True, + ) + except FileTooLargeError as exc: + raise _OverlayTooLarge( + f"workspace cli.json overlay {cli_json} is past the " + f"{_CLI_JSON_MAX_BYTES}-byte ceiling; left unchanged" + ) from exc + if raw is None: + raise OSError(f"workspace cli.json overlay {cli_json} {_OVERLAY_REFUSED}") + try: + file_mtime = int(os.stat(cli_json, follow_symlinks=False).st_mtime) + except OSError: + # The read remains authoritative. A racing replacement or failed + # metadata lookup only voids ownership, the conservative side. + file_mtime = None + + try: + existing = json.loads(raw.decode(_CLI_JSON_ENCODING)) + except Exception as exc: + # Every failure to turn bytes into a settings object leaves the operator's + # file unchanged. + raise ValueError( + f"workspace cli.json overlay {cli_json} could not be parsed as a settings document; " + "left unchanged" + ) from exc + if not isinstance(existing, dict): + raise ValueError( + f"workspace cli.json overlay {cli_json} is not a JSON object; left unchanged" + ) + return existing, file_mtime, file_version + + def _write_cli_overlay( work_dir: Path, model: str, effort: str, *, timeout: float = CLI_SETTINGS_LOCK_TIMEOUT_SECS, + replace_unowned: bool = False, ) -> None: """Write a workspace cli.json overlay so kiro-cli applies effort at spawn. Path: ``/.kiro/settings/cli.json``. Workspace settings override the global ``~/.kiro/settings/cli.json``, so nothing here touches the user's global kiro settings. The overlay's scope is that WORK DIRECTORY, not a - session: every session served by one runtime shares the work dir, so the - file is shared by all of them, and kiro-cli reads it only at spawn. A write - therefore reaches no session that is already running -- it lands on the next - respawn, and then for every session that respawn serves. Merge-safe and - idempotent — safe to call before every spawn. - - Its unit of state is therefore ``(work_dir, model)`` and never - ``(session, model)``: two sessions on one runtime running the same model have - one entry between them, and the last write is the one a respawn reads. - Nothing reaches this writer except :class:`AcpProvider`, which is the object - that spawns the runtime and so always owns it, which is why a live effort - change is pushed to the running session separately - (:meth:`AcpProvider.change_effort`) rather than through this file. A session - that joins a runtime it did not spawn has no path here at all today; if one - is added, it needs to say that its write cannot reach the running process. + session: every session served by one runtime shares the work dir. Kiro-cli + reads the file at spawn, while Kiro Crew reasserts a resolved level after + spawn and after each fresh session on a warm runtime. Merge-safe and + idempotent -- safe to call before every spawn. + + The effort value and Kiro Crew's exact-match ownership record live in the + same document and are published by one atomic write. An existing matching + value with no matching record is operator-owned and is never claimed. A + differing operator-authored active-key value, or a non-object value a + normal projection would replace, is kept. ``replace_unowned`` is reserved + for the explicit Default action: it replaces this model's effort values in + both family shapes while preserving their sibling keys, but refuses a + non-object ``chat.modelDefaults``, model entry, or effort object and leaves + the file unchanged. A stale or malformed record is dropped without changing + any other setting; when no record is stale or malformed the file is + untouched. The effort sub-key is family-specific (``effort_settings_key``): Claude - models use ``output_config``, GPT models use ``reasoning`` — kiro-cli + models use ``output_config``, GPT models use ``reasoning`` -- kiro-cli silently ignores the wrong key, so the shape must match the model. - Format:: - - {"chat.modelDefaults": {"": {"": {"effort": ""}}}} + An existing overlay that cannot be read, is a link, is hardlinked, is + non-regular, escapes the workspace settings directory, exceeds the 1 MiB + read ceiling, or cannot be parsed into a settings object is left byte-for-byte + unchanged and the write raises: the caller reports a level as persisted only + when the file holds it, and an operator's file is never reset to make room + for one. A rewrite whose document would exceed that ceiling is refused the + same way, before anything is written. A settings folder on a sensitive path, + or one whose real path cannot + be verified, is refused by the lock before anything beneath it is read, and + the write raises. Where descriptor-relative writes are supported, the read and publish use + the settings directory the lock opened, so a folder replaced or linked after + the lock cannot receive the write and the writer raises. Elsewhere both go by + name; on Windows the lock holds ``.kiro`` and ``settings`` open meanwhile, so + neither can be renamed or deleted until the writer is done. """ - with workspace_cli_settings_lock(work_dir, timeout=timeout) as cli_json: - try: - existing = ( - json.loads(cli_json.read_text(encoding=_CLI_JSON_ENCODING)) - if cli_json.exists() - else {} + with locked_workspace_cli_settings(work_dir, timeout=timeout) as settings: + existing, file_mtime, file_version = _read_cli_overlay_document(settings) + read_document = copy.deepcopy(existing) + + key = effort_settings_key(model) + model_defaults_present = "chat.modelDefaults" in existing + model_defaults_raw = existing.get("chat.modelDefaults") + model_defaults = model_defaults_raw if isinstance(model_defaults_raw, dict) else None + model_present = model_defaults is not None and model in model_defaults + model_cfg_raw = model_defaults.get(model) if model_defaults is not None else None + model_cfg = model_cfg_raw if isinstance(model_cfg_raw, dict) else None + active_key_present = model_cfg is not None and key in model_cfg + current_effort_cfg_raw = model_cfg.get(key) if model_cfg is not None else None + current_effort_cfg = ( + current_effort_cfg_raw if isinstance(current_effort_cfg_raw, dict) else None + ) + current_effort = ( + current_effort_cfg.get("effort") if current_effort_cfg is not None else None + ) + active_effort_present = current_effort_cfg is not None and "effort" in current_effort_cfg + if replace_unowned: + for path, present, value in ( + ("chat.modelDefaults", model_defaults_present, model_defaults_raw), + (f"chat.modelDefaults.{model}", model_present, model_cfg_raw), + (f"chat.modelDefaults.{model}.{key}", active_key_present, current_effort_cfg_raw), + ): + if present and not isinstance(value, dict): + raise _OverlayParentNotAnObject( + f"workspace cli.json overlay {settings.cli_json} has non-object {path}; " + "left unchanged" + ) + raw_owned = existing.get(_KIROCREW_EFFORT_OWNED_KEY) + owned = _owned_cli_effort(existing, file_mtime) + recorded_effort = owned.get(model) + ownership_needs_normalization = ( + _KIROCREW_EFFORT_OWNED_KEY in existing and raw_owned != owned + ) + replaces_operator_value = not replace_unowned and ( + (model_defaults is None and model_defaults_present) + or ( + (model_defaults is not None and model_cfg is None and model_present) + or (model_cfg is not None and current_effort_cfg is None and active_key_present) + or ( + active_effort_present + and not _cli_effort_is_owned(current_effort, recorded_effort) + ) + ) + ) + if replaces_operator_value: + if recorded_effort is not None or ownership_needs_normalization: + owned.pop(model, None) + if owned: + existing[_KIROCREW_EFFORT_OWNED_KEY] = owned + else: + existing.pop(_KIROCREW_EFFORT_OWNED_KEY, None) + _publish_cli_overlay(settings, existing, read_document, file_mtime, file_version) + logger.debug( + "ACP effort overlay kept operator-authored level: model=%s effort=%s", + model, + _log_effort_level(current_effort), ) - except (json.JSONDecodeError, OSError): - existing = {} - if not isinstance(existing, dict): - existing = {} - model_defaults = existing.get("chat.modelDefaults") - if not isinstance(model_defaults, dict): + return + + if model_defaults is None: model_defaults = {} - model_cfg = model_defaults.get(model) - if not isinstance(model_cfg, dict): + if model_cfg is None: model_cfg = {} - key = effort_settings_key(model) - effort_cfg = model_cfg.get(key) - if not isinstance(effort_cfg, dict): - effort_cfg = {} + if replace_unowned: + for effort_key in ("output_config", "reasoning"): + prior_effort_cfg = model_cfg.get(effort_key) + if not isinstance(prior_effort_cfg, dict): + continue + preserved_effort_cfg = dict(prior_effort_cfg) + preserved_effort_cfg.pop("effort", None) + if preserved_effort_cfg: + model_cfg[effort_key] = preserved_effort_cfg + else: + model_cfg.pop(effort_key, None) + current_effort_cfg = model_cfg.get(key) + effort_cfg = dict(current_effort_cfg) if isinstance(current_effort_cfg, dict) else {} effort_cfg["effort"] = effort model_cfg[key] = effort_cfg - # Recovery checks output_config first, so leaving effort under both family - # keys can resurrect a stale value after restart. - other_key = "reasoning" if key == "output_config" else "output_config" - other_effort_cfg = model_cfg.get(other_key) - if isinstance(other_effort_cfg, dict) and "effort" in other_effort_cfg: - other_effort_cfg.pop("effort") - if not other_effort_cfg: - model_cfg.pop(other_key, None) model_defaults[model] = model_cfg existing["chat.modelDefaults"] = model_defaults - atomic_write( - cli_json, json.dumps(existing, indent=2) - ) # atomic: readers never see a partial file + owned[model] = effort + if owned: + existing[_KIROCREW_EFFORT_OWNED_KEY] = owned + else: + existing.pop(_KIROCREW_EFFORT_OWNED_KEY, None) + + _publish_cli_overlay(settings, existing, read_document, file_mtime, file_version) # The thresholds and their clamps live in ``agent_sdk.tool_search`` so the @@ -174,18 +546,31 @@ def _write_tool_search_overlay( and any other settings already present. kiro cli.json uses flat dotted keys (e.g. ``"chat.modelDefaults"``), so the Tool Search keys are written flat to match. + + An existing overlay that cannot be read, is a link, is hardlinked, is + non-regular, escapes the workspace settings directory, cannot be parsed + into a settings object, or exceeds the shared ceiling is left byte-for-byte + unchanged and the write raises, exactly as :func:`_write_cli_overlay` does: + the effort entries and operator settings in that file are never reset to + make room for this flag. A rewrite whose document would exceed the ceiling + is refused the same way. Where descriptor-relative writes are supported, + the read and publish use the settings directory the lock opened, so a folder + replaced or linked after the lock cannot receive the write and the writer + raises. Elsewhere both go by name; on Windows the lock holds ``.kiro`` and + ``settings`` open meanwhile, so neither can be renamed or deleted until the + writer is done. The provider's caller logs the refused write and starts the + session anyway. """ - with workspace_cli_settings_lock(work_dir) as cli_json: - try: - existing = ( - json.loads(cli_json.read_text(encoding=_CLI_JSON_ENCODING)) - if cli_json.exists() - else {} - ) - except (json.JSONDecodeError, OSError): - existing = {} - if not isinstance(existing, dict): - existing = {} + with locked_workspace_cli_settings(work_dir) as settings: + existing, file_mtime, file_version = _read_cli_overlay_document(settings) + read_document = copy.deepcopy(existing) + raw_owned = existing.get(_KIROCREW_EFFORT_OWNED_KEY) + owned = _owned_cli_effort(existing, file_mtime) + if _KIROCREW_EFFORT_OWNED_KEY in existing and raw_owned != owned: + if owned: + existing[_KIROCREW_EFFORT_OWNED_KEY] = owned + else: + existing.pop(_KIROCREW_EFFORT_OWNED_KEY, None) existing["toolSearch.enabled"] = bool(enabled) if enabled: existing["toolSearch.minPct"] = _clamp_min_pct(min_pct) @@ -195,125 +580,574 @@ def _write_tool_search_overlay( # would take effect if a later build flips the global default on. existing.pop("toolSearch.minPct", None) existing.pop("toolSearch.minTokens", None) - atomic_write( - cli_json, json.dumps(existing, indent=2) - ) # atomic: readers never see a partial file + _publish_cli_overlay(settings, existing, read_document, file_mtime, file_version) -def _clear_cli_overlay_effort(work_dir: Path, model: str) -> bool: - """Remove the effort entry for *model* from the workspace cli.json overlay. +class _BoundedCliEffortPairs: + """Retain a bounded sample of file-derived effort pairs for one log line.""" - Merge-safe: leaves other models' settings intact and drops now-empty - containers. No-op when the file or entry is absent. + def __init__(self) -> None: + self._sample: list[str] = [] + self._omitted = 0 - Returns whether the overlay holds no effort for *model* once this returns. - False means the shared settings lock could not be taken, which with the - ACTION ceiling means a stuck holder rather than the routine contention the - startup ceiling sees -- projection's own critical section is sub-second. That - distinction is what keeps this answer two-valued: a failure mode that happens - by design would need a third state, and one that means "something is wrong" - does not. BLOCKING on the event loop for that ceiling would freeze every - session, so every caller must reach this off the loop. + @property + def sample_size(self) -> int: + """Number of retained, rendered pairs.""" + return len(self._sample) - Every other exit is a success: an absent file, an absent entry, a removed - entry and a malformed file all leave the overlay naming no effort for *model*. + @property + def omitted(self) -> int: + """Number of pairs omitted after the retained sample.""" + return self._omitted + + def add(self, model: object, level: object) -> None: + """Retain one escaped pair or count it as omitted.""" + rendered = f"{_log_model_key(model)}={_log_effort_level(level)}" + if len(self._sample) < _WARN_UNOWNED_MAX_ITEMS: + self._sample.append(rendered) + else: + self._omitted += 1 + + def __bool__(self) -> bool: + return bool(self._sample) or self._omitted > 0 + + def render(self) -> str: + """Render the retained sample and its omitted tail count.""" + rendered = list(self._sample) + if self._omitted: + rendered.append(f"... {self._omitted} more") + return ", ".join(rendered) + + +def _warn_unowned_cli_effort( + cli_json: Path, model_defaults: dict[str, Any], model: str | None +) -> None: + """Log the active-key effort values a Default or ``auto`` spawn may run. + + Called after the ownership sweep, so every value still under a model's + active key is one Kiro Crew did not write: an operator's entry, or a level + an older build projected without a record. ``None`` reads every model in + the document because the ``auto`` spawn's model is unknown. One warning + covers every remaining value; nothing is logged when none remains. """ + candidates = list(model_defaults) if model is None else [model] + pairs = _BoundedCliEffortPairs() + for candidate in candidates: + model_cfg = model_defaults.get(candidate) + if not isinstance(model_cfg, dict): + continue + effort_cfg = model_cfg.get(effort_settings_key(candidate)) + if not isinstance(effort_cfg, dict) or "effort" not in effort_cfg: + continue + pairs.add(candidate, effort_cfg["effort"]) + if not pairs: + return + if model is None: + # The ``auto`` spawn: the picker clears an entry only for the model the + # slot has selected, so the listed model has to be selected first. + remedy = ( + "Remove it by selecting the listed model, picking a level and then Default in " + "the effort picker, or by editing the file" + ) + else: + remedy = ( + "Remove it by picking a level and then Default in the effort picker, or by " + "editing the file" + ) + logger.warning( + "ACP effort overlay %s holds an effort level Kiro Crew did not write (%s): " + "this session is on Default, so it may run that level instead of the model's " + "own default. %s", + cli_json, + pairs.render(), + remedy, + ) + + +def _clear_cli_overlay_effort( + work_dir: Path, + model: str | None, + *, + timeout: float = CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS, + owned_only: bool = True, + warn_unowned: bool = False, +) -> bool: + """Remove matching effort projections from the workspace overlay. + + A normal clear removes only an active-key value that exactly matches Kiro + Crew's record in the same cli.json document. An absent, malformed, stale, + or mismatched record owns nothing. ``None`` applies that rule to every + recorded model. ``owned_only=False`` is the explicit operator clear and + removes either known effort-key shape for the requested model. + + ``warn_unowned`` is the pre-spawn projection's flag: after the sweep it + logs the active-key values the document still holds for the model (every + model for ``None``), because the spawn that follows may run one of them + instead of the default. The values are read from the document already + parsed under the lock; nothing is stored. + + Returns False only when the shared settings lock or an existing overlay + cannot be read or written, is a link, is hardlinked, is non-regular, or + escapes the workspace settings directory, when the settings folder is on a + sensitive path or its real path cannot be verified, or when the settings + folder is replaced during the write. The lock-free absence probe first admits the + settings folder from link text; when admission refuses, no target is probed + and the lock reports False whether or not that target contains ``cli.json``. + An overlay past the ceiling, or one whose rewrite would pass it, returns + False and is left unchanged. A missing file, or one that cannot be parsed + into a settings object, names no effort for anyone, so it is left unchanged + and is a successful no-op. Where descriptor-relative writes are supported, + the read and publish use the settings directory the lock opened, so a folder + replaced or linked after the lock cannot receive the write. Elsewhere both go + by name; on Windows the lock holds ``.kiro`` and ``settings`` open meanwhile, + so neither can be renamed or deleted until the writer is done. + """ + admitted_settings_dir = admitted_workspace_cli_settings_dir(work_dir) + if admitted_settings_dir is not None and not os.path.lexists( + admitted_settings_dir / "cli.json" + ): + return True + try: - with workspace_cli_settings_lock( - work_dir, timeout=CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS - ) as cli_json: - if not cli_json.exists(): - return True + with locked_workspace_cli_settings(work_dir, timeout=timeout) as settings: + cli_json = settings.cli_json try: - data = json.loads(cli_json.read_text(encoding=_CLI_JSON_ENCODING)) - except json.JSONDecodeError: - # A malformed file names no effort for any model, and - # ``_read_cli_overlay`` reads it as ``{}`` too, so a respawn - # re-seeds nothing. The postcondition already holds. - return True + data, file_mtime, file_version = _read_cli_overlay_document(settings) + except _OverlayTooLarge: + logger.warning( + "ACP effort overlay %s is past the %d-byte ceiling; left unchanged", + cli_json, + _CLI_JSON_MAX_BYTES, + ) + return False except OSError: - # The file EXISTS and this call holds the lock, so a read that - # fails here is transient IO (a Windows sharing violation, say), - # NOT an absent entry: the level may well still be on disk. - # Reporting success would be the silent stale reload this - # function's own return value exists to prevent. logger.debug("ACP effort overlay read failed", exc_info=True) return False - if not isinstance(data, dict): - return True - model_defaults = data.get("chat.modelDefaults") - if not isinstance(model_defaults, dict): + except ValueError: + # Every failure to turn bytes into a settings object names no + # effort to clear, so the operator's bytes stay unchanged. return True - model_cfg = model_defaults.get(model) - if isinstance(model_cfg, dict): - # Clear whichever sub-key holds effort. Sweep both known shapes so a - # model whose family key changed (or an overlay written by an older - # build) is fully cleaned up, not just the current-family key. - for key in ("output_config", "reasoning"): + read_document = copy.deepcopy(data) + + raw_owned = data.get(_KIROCREW_EFFORT_OWNED_KEY) + owned = _owned_cli_effort(data, file_mtime) + ownership_changed = _KIROCREW_EFFORT_OWNED_KEY in data and raw_owned != owned + target_models = list(owned) if model is None else [model] + original_model_defaults = data.get("chat.modelDefaults") + if isinstance(original_model_defaults, dict): + model_defaults = original_model_defaults + model_defaults_was_dict = True + else: + model_defaults = {} + model_defaults_was_dict = False + overlay_changed = False + removed_owned = _BoundedCliEffortPairs() + + for target_model in target_models: + recorded_level = owned.pop(target_model, None) + if recorded_level is not None: + ownership_changed = True + model_cfg = model_defaults.get(target_model) + if not isinstance(model_cfg, dict): + continue + + keys = ( + ("output_config", "reasoning") + if not owned_only + else (effort_settings_key(target_model),) + ) + for key in keys: effort_cfg = model_cfg.get(key) - if isinstance(effort_cfg, dict): - effort_cfg.pop("effort", None) - if not effort_cfg: - model_cfg.pop(key, None) + if not isinstance(effort_cfg, dict) or "effort" not in effort_cfg: + continue + if owned_only and not _cli_effort_is_owned( + effort_cfg.get("effort"), recorded_level + ): + continue + if owned_only: + removed_owned.add(target_model, effort_cfg["effort"]) + effort_cfg.pop("effort") + overlay_changed = True + if not effort_cfg: + model_cfg.pop(key, None) if not model_cfg: - model_defaults.pop(model, None) - atomic_write(cli_json, json.dumps(data, indent=2)) # atomic - except OSError: + model_defaults.pop(target_model, None) + + if warn_unowned: + _warn_unowned_cli_effort(cli_json, model_defaults, model) + if not overlay_changed and not ownership_changed: + return True + if model_defaults: + data["chat.modelDefaults"] = model_defaults + elif model_defaults_was_dict: + data.pop("chat.modelDefaults", None) + if owned: + data[_KIROCREW_EFFORT_OWNED_KEY] = owned + else: + data.pop(_KIROCREW_EFFORT_OWNED_KEY, None) + try: + _publish_cli_overlay(settings, data, read_document, file_mtime, file_version) + except _OverlayTooLarge: + logger.warning( + "ACP effort overlay %s would exceed the %d-byte ceiling once rewritten; " + "left unchanged", + cli_json, + _CLI_JSON_MAX_BYTES, + ) + return False + if removed_owned: + logger.info( + "ACP effort overlay %s removed Kiro Crew-owned level(s): %s; if you set a " + "level by hand to the same value, set it again", + cli_json, + removed_owned.render(), + ) + except (OSError, RecursionError): logger.debug("ACP effort overlay clear failed", exc_info=True) return False return True -def _read_cli_overlay(work_dir: Path) -> dict[str, str]: - """Recover ``{model: level}`` from an existing workspace cli.json overlay. +async def _await_overlay_write(write: Callable[[], object]) -> object: + """Run a ``cli.json`` overlay write in a worker thread and wait for it even when cancelled. - Called on provider init so a server restart (which loses in-memory slot - state) still reflects the effort kiro-cli will actually use. Returns ``{}`` - when the file is missing, malformed, or has no effort entries. + A cancelled caller re-raises only once the worker is done, however many times + it is cancelled while it waits, so a teardown that follows (the shutdown + reclaim of a disposable work dir) or a successor's projection never races a + write still in progress. A worker that fails after the cancellation leaves + only the cancellation to raise. """ - cli_json = work_dir / ".kiro" / "settings" / "cli.json" - if not cli_json.exists(): - return {} + write_task = asyncio.ensure_future(asyncio.to_thread(write)) try: - data = json.loads(cli_json.read_text(encoding=_CLI_JSON_ENCODING)) - except (json.JSONDecodeError, OSError): - return {} - if not isinstance(data, dict): - return {} + return await asyncio.shield(write_task) + except asyncio.CancelledError: + # Each further cancellation lands on a fresh shield, never on the worker. + while not write_task.done(): + with suppress(asyncio.CancelledError, Exception): + await asyncio.shield(write_task) + if not write_task.cancelled(): + write_task.exception() + raise + + +# Set by start() around its pre-spawn projection, so the overlay worker (which +# copies the caller's context) keys on the spawn's model; a live projection in +# another task sees None and keys on the running session's model. +_EFFORT_OVERLAY_MODEL_OVERRIDE: ContextVar[str | None] = ContextVar( + "effort_overlay_model_override", default=None +) + + +_EFFORT_OVERLAY_AUTO_CLEAR_WARNING = ( + "ACP effort overlay could not be checked or cleared for the auto " + "model: this spawn may run a level another session projected " + "instead of the default" +) +_EFFORT_OVERLAY_DEFAULT_CLEAR_WARNING = ( + "ACP effort overlay could not be checked or cleared for %s: this " + "spawn may run a level another session projected instead of the " + "default" +) + + +class _WorkspaceEffortOverlayFence: + """Keep Default-branch spawn reads free of gateway-written levels. + + Windows share because each clears effort entries. Arrival order decides the + rest: a window waits for a write queued before it, and a write waits for a + held write, for held windows, and for windows queued before it. Neither side + can then be starved by a stream of arrivals on the other. A window that + waited only for a HELD write would let back-to-back Default starts overtake a + queued pick until its ceiling expires and the handler resets the picker's + live session. + + A window waits for writes queued BEFORE it rather than for any queued write, + because the pair "write held, window waiting, write arriving" has to resolve: + the arriving write waits for that window, so a window waiting for it would + leave the two waiting on each other. + + A waiting write counts held windows as its own predecessors without tracking + their arrival, because a window queued after a waiting write cannot acquire + while that write waits, so every held window was queued before it. + """ + + def __init__(self) -> None: + self._windows = 0 + self._write_held = False + self._next_ticket = 0 + self._waiting_windows: set[int] = set() + self._waiting_writes: set[int] = set() + self._changed = asyncio.Event() + + @property + def _pending_windows(self) -> int: + return len(self._waiting_windows) + + def _excluded(self, mode: Literal["window", "write"], ticket: int) -> bool: + if mode == "window": + return self._write_held or any(queued < ticket for queued in self._waiting_writes) + return ( + self._write_held + or bool(self._windows) + or any(queued < ticket for queued in self._waiting_windows) + ) + + def _wake(self) -> None: + # One event per state change, swapped as it fires: a waiter holds the + # object it armed on, so one that arrives after the swap waits for the + # next change instead of returning from an event already set. + self._changed.set() + self._changed = asyncio.Event() + + async def acquire(self, mode: Literal["window", "write"], timeout: float) -> bool: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + ticket = self._next_ticket + self._next_ticket += 1 + waiting = self._waiting_windows if mode == "window" else self._waiting_writes + # No await between taking the ticket and the first check, so a caller is + # queued before anyone can read the queue it has to be ordered against. + waiting.add(ticket) + acquired = False + try: + while self._excluded(mode, ticket): + changed = self._changed + remaining = deadline - loop.time() + if remaining <= 0: + return False + try: + await asyncio.wait_for(changed.wait(), remaining) + except asyncio.TimeoutError: + return False + if mode == "window": + self._windows += 1 + else: + self._write_held = True + acquired = True + return True + finally: + waiting.discard(ticket) + if not acquired: + # A waiter that leaves on its timeout, or cancelled, wakes + # whoever its place in the queue was holding off. + self._wake() + + async def wait_until_writes_clear(self, timeout: float) -> bool: + """Wait, registering NOTHING here, until no write is held or waiting. + + Registering nothing is the point: the caller waits for its gateway-wide + cold-start permit after this returns, and a ticket left behind would hold + writes off for that whole queue wait, in a directory the queue knows + nothing about. A caller that registers nothing can never be a writer's + predecessor, so it cannot starve one either. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while self._write_held or self._waiting_writes: + changed = self._changed + remaining = deadline - loop.time() + if remaining <= 0: + return False + try: + await asyncio.wait_for(changed.wait(), remaining) + except asyncio.TimeoutError: + return False + return True + + def try_acquire_window(self) -> bool: + """Take a window if no write is held or waiting, without awaiting. + + Await-free so a caller that has just been granted its cold-start permit + can read the fence and take the window in one step on the loop: an await + between the two would let a write queue in behind the reading and then be + held off by a window taken on it. + """ + if self._write_held or self._waiting_writes: + return False + self._windows += 1 + return True + + def release(self, mode: Literal["window", "write"]) -> None: + if mode == "window": + self._windows -= 1 + else: + self._write_held = False + self._wake() + + +_WORKSPACE_EFFORT_FENCES: weakref.WeakKeyDictionary[ + asyncio.AbstractEventLoop, weakref.WeakValueDictionary[str, _WorkspaceEffortOverlayFence] +] = weakref.WeakKeyDictionary() + + +def _workspace_effort_overlay_fence(real_settings_dir: str) -> _WorkspaceEffortOverlayFence: + """Get or create this loop's fence for an already-resolved settings directory. + + The key is the settings folder, not the work dir: the settings lock follows a + ``.kiro`` or ``settings`` link that stays inside the work dir, so two work + dirs can share one settings folder, and their windows and writes must then + exclude each other. + + This stays synchronous and await-free because the get and create form one + critical section on the loop. An await between them could give two coroutines + separate fences for one directory, which would exclude nothing. + """ + loop = asyncio.get_running_loop() + fences = _WORKSPACE_EFFORT_FENCES.setdefault(loop, weakref.WeakValueDictionary()) + fence = fences.get(real_settings_dir) + if fence is None: + fence = _WorkspaceEffortOverlayFence() + fences[real_settings_dir] = fence + return fence + + +@asynccontextmanager +async def _hold_workspace_effort_overlay_fence( + work_dir: Path, mode: Literal["window", "write"], timeout: float +) -> AsyncIterator[bool]: + # Off the loop: even trusted work-directory resolution can pause on a slow mount. + # The helper admits each settings link from lstat/readlink text before keying it. + # Admission refusal uses the resolved work dir plus literal names; failure to + # resolve the work dir still fails the start. + real_settings_dir = await asyncio.to_thread(workspace_cli_settings_fence_key, work_dir) + fence = _workspace_effort_overlay_fence(real_settings_dir) + acquired = await fence.acquire(mode, timeout) + try: + yield acquired + finally: + if acquired: + fence.release(mode) + + +@asynccontextmanager +async def _hold_workspace_effort_overlay_window_under_admission( + work_dir: Path, + admission: Callable[[], AbstractAsyncContextManager[Any]], + timeout: float, +) -> AsyncIterator[bool]: + """Hold the window only while also holding a cold-start permit, taking neither first. + + Three orderings are wrong and this is the fourth. Window then permit holds a + work directory off for a gateway-wide queue wait, so one pool fill can stall + a level pick in the directory Default chats share. Permit then a BLOCKING + window wait holds a gateway-wide permit for the fence ceiling, so two starts + in one directory can hold every admission slot while they wait on a fence + that cold starts in other projects have nothing to do with. Registering on + the fence while queued makes the start a writer's predecessor for that queue + wait, which is the starvation the fairness rules exist to prevent. + + So: wait for the directory to be free holding NOTHING, queue for the permit + holding nothing on the fence, and take the window with the permit in hand + only if the directory is still free at that instant. A write that arrived + while this start was queued gets the permit handed straight back, and the + start waits for the directory again rather than for the write to finish with + a permit held. + + ``timeout`` bounds the TOTAL time spent waiting for the directory across + those attempts, and never the admission queue wait: the queue is the + gateway's own admission control, and a start that waited its turn there has + not been starved by anything this fence does. + """ + # Off the loop once, before the loop: the resolution reads the settings link + # and must not be repeated per attempt. + real_settings_dir = await asyncio.to_thread(workspace_cli_settings_fence_key, work_dir) + fence = _workspace_effort_overlay_fence(real_settings_dir) + loop = asyncio.get_running_loop() + spent = 0.0 + while True: + remaining = timeout - spent + if remaining <= 0: + yield False + return + waited_from = loop.time() + cleared = await fence.wait_until_writes_clear(remaining) + spent += loop.time() - waited_from + if not cleared: + yield False + return + async with admission(): + if fence.try_acquire_window(): + try: + yield True + finally: + fence.release("window") + return + # Permit handed back by leaving the block above, so the write that + # arrived while this start was queued is held off by nothing. + + +def _cli_overlay_effort_models( + work_dir: Path, timeout: float = CLI_SETTINGS_LOCK_TIMEOUT_SECS +) -> set[str] | None: + """Return models whose workspace overlay contains effort under the key kiro-cli reads. + + ``None`` means the same guarded reader used by the writers refused the file. + Callers use this snapshot only while holding the spawn window, immediately + before kiro-cli reads the file. The nested settings lock cannot delay another + start because window holders share the window, while writers already wait for + every window holder to finish. + """ + admitted_settings_dir = admitted_workspace_cli_settings_dir(work_dir) + if admitted_settings_dir is not None and not os.path.lexists( + admitted_settings_dir / "cli.json" + ): + return set() + try: + with locked_workspace_cli_settings(work_dir, timeout=timeout) as settings: + data, _, _ = _read_cli_overlay_document(settings) + except (OSError, RecursionError, ValueError): + logger.debug("ACP effort overlay snapshot failed", exc_info=True) + return None model_defaults = data.get("chat.modelDefaults") if not isinstance(model_defaults, dict): - return {} - out: dict[str, str] = {} - for model, cfg in model_defaults.items(): - if not isinstance(cfg, dict): - continue - # Effort lives under output_config (Claude) or reasoning (GPT); read - # whichever is present so recovery works for both families. - for key in ("output_config", "reasoning"): - eff_cfg = cfg.get(key) - if isinstance(eff_cfg, dict): - eff = eff_cfg.get("effort") - if isinstance(eff, str) and eff: - out[model] = eff - break - return out + return set() + return { + model + for model, config in model_defaults.items() + if isinstance(model, str) + and isinstance(config, dict) + and isinstance(config.get(effort_settings_key(model)), dict) + and "effort" in config[effort_settings_key(model)] + } def _apply_effort_overlay( - self: AcpProvider, *, timeout: float = CLI_SETTINGS_LOCK_TIMEOUT_SECS + self: AcpProvider, + *, + timeout: float = CLI_SETTINGS_LOCK_TIMEOUT_SECS, + replace_unowned: bool = False, ) -> bool: - """Write the kiro workspace cli.json overlay for (current model, effort). + """Project this session's effort for the current model into the kiro overlay. Written only for the harnesses that READ it (``ACP_BACKENDS_EFFORT_OVERLAY`` — kiro-cli takes effort from this file at spawn); every other harness takes - its level live, KAS as the ``effortLevel`` config option. Also a no-op when - the model is not effort-capable or no level resolves. Called before every - (re)spawn so resume/restart keeps the same level. + its level live, KAS as the ``effortLevel`` config option. A no-op when a concrete model is not effort-capable. Called + before every (re)spawn so resume/restart keeps the same level. + + A resolved level and its exact-match ownership record are written in one + atomic cli.json update unless an operator-authored active-key value or + non-object shape would be replaced. Those values stay in the file, and + the session receives its own resolved level through the post-spawn + ``/effort`` push. ``replace_unowned``, passed only by the explicit + Default, replaces them instead (see ``_write_cli_overlay``). When none + resolves for a concrete model, that model's entry is removed only when + the in-file record names the exact active-key value still present. For + ``auto``, the same rule is applied to every recorded model because the + concrete model is unknowable until after kiro-cli reads the overlay. + Before-spawn callers override the projection key with + :attr:`_spawn_effort_model`, so a spawn with nothing pinned projects as + ``auto`` whatever the last session served. Live callers key on + :attr:`_effort_model`, the model the running session serves. + Operator-authored entries and values edited after projection are + preserved, and stale records are dropped. The removal admits the settings + folder from link text before its lock-free ``cli.json`` probe, so an + outside or remote target is not inspected; Default then reports that it + could not change the file through the existing lock-failure path. - KAS is not written: the file is shared by every session in the work dir and - construction reads it back, so a level one KAS chat left there would start - the next one. A KAS slot's own level survives a gateway restart through the - slot's metadata instead, which the factory hands back as its override. + KAS is not written: kiro-cli's KAS engine never reads the file, and a + level a KAS chat left there would start a kiro-cli chat in the same work + dir. A KAS slot's own level survives a gateway restart through the slot's + metadata instead, which the factory hands back as its override. Membership rather than "not claude": the companion reads and clears (provider construction, :meth:`change_effort`, :meth:`clear_effort`) are gated on the @@ -322,21 +1156,143 @@ def _apply_effort_overlay( """ if self._client.backend not in ACP_BACKENDS_EFFORT_OVERLAY: return True - model = self._effort_model + model_override = _EFFORT_OVERLAY_MODEL_OVERRIDE.get() + model = self._effort_model if model_override is None else model_override + work_dir = self._client._work_dir + admitted_settings_dir = admitted_workspace_cli_settings_dir(work_dir) + cli_json = admitted_settings_dir / "cli.json" if admitted_settings_dir is not None else None + if model == DEFAULT_MODEL: + if cli_json is not None and not os.path.lexists(cli_json): + return True + if not _clear_cli_overlay_effort(work_dir, None, timeout=timeout, warn_unowned=True): + logger.warning(_EFFORT_OVERLAY_AUTO_CLEAR_WARNING) + return False + logger.debug("ACP effort ownership checked for the auto model") + return True + if not model or not self.supports_effort(): + return True level = self._resolve_effort() - if not model or not level: + if not level: + if cli_json is not None and not os.path.lexists(cli_json): + return True + if not _clear_cli_overlay_effort( + work_dir, + model, + timeout=timeout, + owned_only=not replace_unowned, + warn_unowned=not replace_unowned, + ): + logger.warning(_EFFORT_OVERLAY_DEFAULT_CLEAR_WARNING, model) + return False + logger.debug("ACP effort ownership checked at the default: model=%s", model) return True try: - _write_cli_overlay(self._client._work_dir, model, level, timeout=timeout) + _write_cli_overlay( + work_dir, + model, + level, + timeout=timeout, + replace_unowned=replace_unowned, + ) except Exception: - # The FILE is what a respawn reads, so a caller that reports a live - # change on a failed write promises a level the next reset undoes. + # A respawn reads the FILE, unless an operator-authored entry was + # kept and the post-spawn push carries this session's level. A failed + # write otherwise must not promise a level the next reset undoes. logger.warning("ACP effort overlay write failed", exc_info=True) return False logger.debug("ACP effort overlay applied: model=%s effort=%s", model, level) return True +def _effort_overlay_fence_mode(self: AcpProvider) -> Literal["window", "write"] | None: + if self._client.backend not in ACP_BACKENDS_EFFORT_OVERLAY: + return None + model = self._spawn_effort_model + if model == DEFAULT_MODEL: + return "window" + if model and self.supports_effort(): + return "write" if self._resolve_effort() else "window" + return None + + +def _log_effort_overlay_projection_skipped(self: AcpProvider, mode: Literal["write"]) -> None: + model = self._spawn_effort_model + if model == DEFAULT_MODEL: + logger.warning(_EFFORT_OVERLAY_AUTO_CLEAR_WARNING) + else: + logger.warning( + "ACP effort overlay write was skipped for %s; post-spawn effort " + "reassertion will apply its resolved level", + model, + ) + + +def _project_explicit_effort_default(self: AcpProvider) -> None: + """Replace a pinned model's entry and record whether that succeeds. + + Runs in the overlay worker and records the outcome with the write, so a + start cancelled while it waits still knows the file changed. A harness + that reads the overlay cannot spend the intent until the backend reports + the model it served: an unpinned start may resolve to any model, and a + pinned start may have its pin refused. Every other harness takes the + pinned path whatever is pinned, because the file its session never opens + needs no change, so the intent is spent here rather than left pending + forever. + """ + self._explicit_effort_default_projected = False + overlay_backend = self._client.backend in ACP_BACKENDS_EFFORT_OVERLAY + if self._spawn_effort_model == DEFAULT_MODEL and overlay_backend: + self._apply_effort_overlay() + return + if self._apply_effort_overlay(replace_unowned=True): + if overlay_backend: + self._explicit_effort_default_projected = True + else: + self._explicit_effort_default_applied = True + self._explicit_effort_default_pending = False + + +def _spawn_effort_model(self: AcpProvider) -> str: + """The model a projection before a spawn keys on: the pin, else ``auto``. + + With nothing pinned, kiro-cli picks its model after it reads the + overlay, and the model the last session served need not be the one the + next serves, so a projection keyed on it could leave another chat's + level for the model the spawn picks. ``auto`` makes the projection + consider every recorded model, as for an ``auto`` pin. + """ + model = self._client._model + return model if model and model != DEFAULT_MODEL else DEFAULT_MODEL + + +async def _write_effort_overlay( + self: AcpProvider, write: Callable[[], object], timeout: float +) -> object | bool: + if self._client.backend not in ACP_BACKENDS_EFFORT_OVERLAY: + return await _await_overlay_write(write) + async with _hold_workspace_effort_overlay_fence( + self._client._work_dir, "write", timeout + ) as acquired: + if not acquired: + return False + return await _await_overlay_write(write) + + +def arm_explicit_effort_default( + self: AcpProvider, fence_rewrite: Callable[[], AbstractContextManager[object]] +) -> None: + """Make the next start apply explicit Default once its target model is known.""" + self._explicit_effort_default_pending = True + self._explicit_effort_default_applied = False + self._explicit_effort_default_projected = False + self._fence_explicit_effort_default_rewrite = fence_rewrite + + +def explicit_effort_default_applied(self: AcpProvider) -> bool: + """Whether this provider replaced its pending Default overlay successfully.""" + return self._explicit_effort_default_applied + + def _apply_tool_search_overlay(self: AcpProvider) -> None: """Write the kiro Tool Search setting into the workspace cli.json overlay. diff --git a/src/kiro_crew/providers/acp_lifecycle.py b/src/kiro_crew/providers/acp_lifecycle.py index 2ee9cb35078..6a40aeded84 100644 --- a/src/kiro_crew/providers/acp_lifecycle.py +++ b/src/kiro_crew/providers/acp_lifecycle.py @@ -9,24 +9,40 @@ class binds it by name, so it reads and writes the session's own state on the in and a patch of the method on the class or on an instance reaches every caller. Instance fields written: ``_disposable_work_dir``, ``_effort_per_model``, +``_explicit_effort_default_applied``, ``_explicit_effort_default_pending``, ``_work_dir_claim_probe``. -Instance fields read: ``_client``, ``_configured_model``, ``essential_delivery``, -``member_context``, ``memory_mode``. +Instance fields read: ``_client``, ``_configured_model``, +``_defer_replay_sid_promotion``, ``_explicit_effort_default_projected``, +``_fence_explicit_effort_default_rewrite``, ``_history_replay_needed``, +``_on_gate_acquired``, ``_on_gate_queued``, ``essential_delivery``, ``member_context``, +``memory_mode``, ``start_priority``. """ from __future__ import annotations import asyncio import logging -from collections.abc import Callable +from collections.abc import Awaitable, Callable from pathlib import Path -from typing import TYPE_CHECKING, AsyncContextManager +from typing import TYPE_CHECKING, AsyncContextManager, Literal from kiro_crew.acp.client import DEFAULT_MODEL, AcpClient +from kiro_crew.acp.types import ACP_BACKENDS_EFFORT_OVERLAY from kiro_crew.config.paths import kiro_sessions_dir +from kiro_crew.providers.acp_cli_overlay import ( + _EFFORT_OVERLAY_MODEL_OVERRIDE, + _WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS, + _await_overlay_write, + _clear_cli_overlay_effort, + _cli_overlay_effort_models, + _hold_workspace_effort_overlay_fence, + _hold_workspace_effort_overlay_window_under_admission, + _write_cli_overlay, +) from kiro_crew.providers.cleanup import _is_safe_path from kiro_crew.session_work_dir import mark_run_dir, reclaim_session_work_dir +from kiro_crew.workspace_cli_settings import CLI_SETTINGS_LOCK_TIMEOUT_SECS if TYPE_CHECKING: from kiro_crew.providers.acp import AcpProvider @@ -47,12 +63,194 @@ def _bound_remotely(session_key: str | None) -> bool: ) +async def _finish_unpinned_explicit_effort_default( + provider: AcpProvider, effort_models_at_spawn: set[str] | None +) -> Literal["applied", "push", "restart", "pending"]: + """Finish explicit Default for the model startup actually served. + + Used by unpinned starts and by pinned starts whose pin was substituted. + ``applied`` means no live update is needed, ``push`` means consume only after + the projected level is pushed live, ``restart`` means a cleared entry needs a + fresh runtime, and ``pending`` means the intent could not be applied yet. + """ + model = provider.served_model + if not model: + logger.warning( + "Explicit effort Default remains pending because the served model is unknown" + ) + return "pending" + if effort_models_at_spawn is None: + logger.warning( + "Explicit effort Default remains pending for served model %s because the " + "workspace overlay was not projected or could not be read before spawn", + model, + ) + return "pending" + if not provider.supports_effort() or model not in effort_models_at_spawn: + return "applied" + + level = provider._resolve_effort() + try: + async with _hold_workspace_effort_overlay_fence( + provider._client._work_dir, "write", CLI_SETTINGS_LOCK_TIMEOUT_SECS + ) as acquired: + if not acquired: + raise TimeoutError("the workspace overlay write fence timed out") + with provider._fence_explicit_effort_default_rewrite(): + if level: + await _await_overlay_write( + lambda: _write_cli_overlay( + provider._client._work_dir, + model, + level, + timeout=CLI_SETTINGS_LOCK_TIMEOUT_SECS, + replace_unowned=True, + ) + ) + elif not await _await_overlay_write( + lambda: _clear_cli_overlay_effort( + provider._client._work_dir, + model, + timeout=CLI_SETTINGS_LOCK_TIMEOUT_SECS, + owned_only=False, + ) + ): + raise OSError("the workspace overlay could not be rewritten") + except Exception as exc: + logger.warning( + "Explicit effort Default remains pending for served model %s because the " + "workspace overlay rewrite failed: %s", + model, + exc, + ) + return "pending" + return "push" if level else "restart" + + +async def _restart_for_explicit_effort_default( + provider: AcpProvider, + placeholder_client: AcpClient, + initial_resume_session_id: str, + initial_history_replay_needed: bool, + initial_defer_replay_sid_promotion: bool, + project_and_start: Callable[[bool], Awaitable[set[str] | None]], +) -> bool: + """Restart once from the start-entry resume state; report whether Default took. + + Raises when the first runtime cannot be stopped or the replacement still reads + its served model's effort entry, so the start fails instead of publishing it. + """ + first_client = provider._client + if first_client is placeholder_client: + logger.warning( + "Explicit effort Default remains pending because the running session " + "cannot be restarted" + ) + return False + if getattr(first_client, "resumed", False): + logger.info( + "Explicit effort Default removed the served model's workspace entry, but a " + "session restored with session/load keeps the level it last ran at until its " + "next fresh session" + ) + return True + first_session_id = getattr(first_client, "_session_id", "") or "" + try: + await first_client.shutdown() + except Exception as exc: + logger.warning( + "Explicit effort Default remains pending because shutting down the " + "first runtime failed: %s", + exc, + ) + raise RuntimeError("shutting down the first runtime failed") from exc + + process_alive = getattr(first_client, "is_alive", None) + if callable(process_alive) and process_alive(): + logger.warning( + "Explicit effort Default remains pending because the first runtime did not stop" + ) + raise RuntimeError("the first runtime did not stop") + + # A persistent shutdown keeps the first session's transcript, and the + # replacement restores the original resume id, so that session can never be + # loaded again and no session-map row names its files for the prune to reach. + # Its runtime has stopped, so deleting them here races nothing. + if first_session_id and first_session_id != initial_resume_session_id: + try: + await provider.cleanup_session(first_session_id) + except Exception as exc: + logger.warning( + "Explicit effort Default could not delete the restarted first session's " + "files for %s: %s", + first_session_id, + exc, + ) + + # Repeat the first start's resume decision. A new chat stays fresh, while a + # Tool Search replay start receives the same persisted id and chooses replay + # again. The replay flags are put back because they are the start's outputs, + # not inputs to that decision: allocation reads them after ``start()`` returns + # to arm ``provider_switch_replay``, and ``defer_replay_sid_promotion`` is read + # from the provider the same way, so a flag only the first attempt's outcome + # set must not be published for a replacement that did not produce it. + placeholder_client.memory_mode = provider.memory_mode + placeholder_client._resume_session_id = initial_resume_session_id + provider._history_replay_needed = initial_history_replay_needed + provider._defer_replay_sid_promotion = initial_defer_replay_sid_promotion + provider._client = placeholder_client + try: + second_effort_models = await project_and_start(True) + except Exception as exc: + provider._client = placeholder_client + logger.warning( + "Explicit effort Default remains pending because the replacement runtime " + "failed to start: %s", + exc, + ) + raise + + second_model = provider.served_model + if not second_model: + logger.warning( + "Explicit effort Default remains pending because the replacement runtime's " + "served model is unknown" + ) + return False + if second_effort_models is None: + logger.warning( + "Explicit effort Default remains pending for replacement model %s because " + "the workspace overlay was not projected or could not be read before spawn", + second_model, + ) + return False + if provider.supports_effort() and second_model in second_effort_models: + logger.warning( + "Explicit effort Default start fails because replacement model %s still " + "had its own effort entry; intent remains pending and no third runtime " + "was started", + second_model, + ) + raise RuntimeError("replacement runtime still reads its served model's effort entry") + if getattr(provider._client, "resumed", False): + logger.info( + "Explicit effort Default removed the served model's workspace entry, but a " + "session restored with session/load keeps the level it last ran at until its " + "next fresh session" + ) + return True + + async def start(self: AcpProvider) -> None: if self.memory_mode not in {"persistent", "incognito", "temporary"}: raise ValueError("Invalid session memory mode") + placeholder_client = self._client self._client.memory_mode = self.memory_mode if self.memory_mode != "persistent": self._client._resume_session_id = "" + initial_resume_session_id = self._client._resume_session_id or "" + initial_history_replay_needed = self._history_replay_needed + initial_defer_replay_sid_promotion = self._defer_replay_sid_promotion self.essential_delivery.invalidate() if self._disposable_work_dir: # The ONE place the directory's provenance is written: before any @@ -63,45 +261,195 @@ async def start(self: AcpProvider) -> None: await asyncio.to_thread(mark_run_dir, Path(self._client._work_dir)) # Re-apply the overlay on every (re)start to cover resume / model swap. # (no-op for claude backend — that path applies effort live below.) - # Off the loop: the cli.json lock is shared with sibling threads and - # processes, and an acquire on the loop thread makes one attempt and - # never waits, so a brief overlap would refuse the write. - await asyncio.to_thread(self._apply_effort_overlay) - await asyncio.to_thread(self._apply_tool_search_overlay) - - if self.is_acp_runtime_backend: - # ── Kiro unified path: AcpRuntime + AcpSessionHandle ── - # Spawn a runtime, create/resume a session, wrap in - # AcpSessionProvider. One process hosts parent + all subagent - # sessions (session sharing). - await self._start_kiro_runtime() - if self.is_kiro_backend: - await self._note_zero_tool_servers() - else: - # ── CC path: legacy AcpClient ── - # Only the runtime arm can fence a remote workspace, so refuse here. - if await asyncio.to_thread(_bound_remotely, getattr(self._client, "_session_key", None)): - from kiro_crew.acp.runtime import AcpRuntimeError - from kiro_crew.session_environment import REMOTE_NEEDS_KAS - - raise AcpRuntimeError(REMOTE_NEEDS_KAS) - self._client.member_context = self.member_context - await self._client.ensure_ready() - # Startup records the advertised spelling of the pin, or the backend - # default when every spelling was refused; the level follows a pin. - running = self._client._model - if running != DEFAULT_MODEL and self._configured_model in self._effort_per_model: - level = self._effort_per_model.pop(self._configured_model) - self._effort_per_model.setdefault(running, level) - # An enrolled member's saved spec is confirmed against what this session - # actually consumed, once the session exists. Called on THIS arm only: the - # Kiro and KAS runtimes take the branch above, so their construction paths - # gain no call, await or branch for it (harness-parity H13). A no-op for - # every session that projects nothing. - if isinstance(self._client, AcpClient): - await self._client.confirm_member_projection() + # Off the loop: on the loop thread the settings lock is single-shot, so + # a start that met another chat's change_effort mid-write gave up at once + # and spawned into the level that chat left. Off-loop the startup + # ceiling (the method's default) is a real wait, short enough for a + # launch to pay. + # An explicit Default records the remaining entries before spawn and + # disarms only after startup confirms the pin was served or the served model + # runs its Default. A concrete pin is still replaced before spawn, while an + # unpinned start keeps the ownership guard. + # Only for the harnesses that READ the overlay (ACP_BACKENDS_EFFORT_OVERLAY), + # the same set the writers, reads and clears are gated on. A harness that + # never opens cli.json would otherwise snapshot and rewrite a file it does + # not read, removing the entry a kiro-cli chat in the same work directory + # spawns on, and either pay a restart for it (KAS) or, unable to restart, + # never consume the intent and repeat that removal at every start (claude); + # its own level arrives live instead. + explicit_default_model = self._spawn_effort_model + finish_explicit_default_after_start = ( + self._explicit_effort_default_pending + and self._client.backend in ACP_BACKENDS_EFFORT_OVERLAY + ) + finish_unpinned_explicit_default = ( + finish_explicit_default_after_start and explicit_default_model == DEFAULT_MODEL + ) + fence_mode = self._effort_overlay_fence_mode() + + async def project_overlay(capture_effort_models: bool) -> set[str] | None: + projection_model_token = _EFFORT_OVERLAY_MODEL_OVERRIDE.set(self._spawn_effort_model) + try: + if finish_unpinned_explicit_default: + with self._fence_explicit_effort_default_rewrite(): + projected = bool(await _await_overlay_write(self._apply_effort_overlay)) + elif self._explicit_effort_default_pending: + with self._fence_explicit_effort_default_rewrite(): + await _await_overlay_write(self._project_explicit_effort_default) + projected = ( + self._explicit_effort_default_projected or self._explicit_effort_default_applied + ) + else: + projected = bool(await _await_overlay_write(self._apply_effort_overlay)) + if capture_effort_models and projected: + effort_models = await _await_overlay_write( + lambda: _cli_overlay_effort_models( + self._client._work_dir, CLI_SETTINGS_LOCK_TIMEOUT_SECS + ) + ) + if effort_models is None or isinstance(effort_models, set): + return effort_models + raise TypeError("effort overlay snapshot returned an invalid value") + return None + finally: + _EFFORT_OVERLAY_MODEL_OVERRIDE.reset(projection_model_token) + + async def start_runtime() -> None: + if self.is_acp_runtime_backend: + # ── Kiro unified path: AcpRuntime + AcpSessionHandle ── + # Spawn a runtime, create/resume a session, wrap in + # AcpSessionProvider. One process hosts parent + all subagent + # sessions (session sharing). + await self._start_kiro_runtime() + else: + # ── CC path: legacy AcpClient ── + # Only the runtime arm can fence a remote workspace, so refuse here. + if await asyncio.to_thread( + _bound_remotely, getattr(self._client, "_session_key", None) + ): + from kiro_crew.acp.runtime import AcpRuntimeError + from kiro_crew.session_environment import REMOTE_NEEDS_KAS + + raise AcpRuntimeError(REMOTE_NEEDS_KAS) + self._client.member_context = self.member_context + await self._client.ensure_ready() + # Startup records the advertised spelling of the pin, or the backend + # default when every spelling was refused; the level follows a pin. + running = self._client._model + if running != DEFAULT_MODEL and self._configured_model in self._effort_per_model: + level = self._effort_per_model.pop(self._configured_model) + self._effort_per_model.setdefault(running, level) + # An enrolled member's saved spec is confirmed against what this session + # actually consumed, once the session exists. Called on THIS arm only: the + # Kiro and KAS runtimes take the branch above, so their construction paths + # gain no call, await or branch for it (harness-parity H13). A no-op for + # every session that projects nothing. + if isinstance(self._client, AcpClient): + await self._client.confirm_member_projection() + + async def start_under_held_window( + acquired: bool, capture_effort_models: bool + ) -> set[str] | None: + if not acquired: + logger.error( + "ACP Default effort overlay fence wait timed out for work directory %s; " + "refusing to start before a gateway write can finish", + self._client._work_dir, + ) + raise RuntimeError( + "Default effort overlay fence wait timed out for work directory " + f"{self._client._work_dir}" + ) + effort_models = await project_overlay(capture_effort_models) + await _await_overlay_write(self._apply_tool_search_overlay) + await start_runtime() + return effort_models + + async def project_under_admitted_window_and_start( + capture_effort_models: bool, + ) -> set[str] | None: + from kiro_crew.acp.runtime_spawn_entry import handed_down_cold_start_admission + + def admission() -> AsyncContextManager[object]: + return handed_down_cold_start_admission( + self.start_priority, + on_gate_queued=self._on_gate_queued, + on_gate_acquired=self._on_gate_acquired, + ) + + async with _hold_workspace_effort_overlay_window_under_admission( + self._client._work_dir, + admission, + _WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS, + ) as acquired: + return await start_under_held_window(acquired, capture_effort_models) + + async def project_and_start(capture_effort_models: bool) -> set[str] | None: + if fence_mode == "window": + # Only kiro-cli reads the overlay (ACP_BACKENDS_EFFORT_OVERLAY), and it + # always runs on the shared runtime, so a window start is always a + # runtime start. Neither the window nor the permit is taken first: + # the start waits for the work directory holding nothing, queues for + # the permit holding nothing on the fence, and takes the window only + # with the permit already in hand. So a start that is merely queued + # holds no window, and a start waiting on a fence holds no + # gateway-wide admission slot. The permit it ends up with is handed + # to this start's first spawn. + return await project_under_admitted_window_and_start(capture_effort_models) + if fence_mode == "write": + async with _hold_workspace_effort_overlay_fence( + self._client._work_dir, "write", CLI_SETTINGS_LOCK_TIMEOUT_SECS + ) as acquired: + if acquired: + effort_models = await project_overlay(capture_effort_models) + else: + self._log_effort_overlay_projection_skipped("write") + effort_models = None + else: + effort_models = await project_overlay(capture_effort_models) + await _await_overlay_write(self._apply_tool_search_overlay) + await start_runtime() + return effort_models + + effort_models_at_spawn = await project_and_start(finish_explicit_default_after_start) + default_needs_push = False + if finish_explicit_default_after_start: + served_pinned_model = ( + explicit_default_model != DEFAULT_MODEL + and effort_models_at_spawn is not None + and self.served_model == explicit_default_model + ) + if served_pinned_model: + self._explicit_effort_default_applied = True + self._explicit_effort_default_pending = False + else: + outcome = await _finish_unpinned_explicit_effort_default(self, effort_models_at_spawn) + if outcome == "applied": + self._explicit_effort_default_applied = True + self._explicit_effort_default_pending = False + elif outcome == "push": + default_needs_push = True + elif outcome == "restart" and await _restart_for_explicit_effort_default( + self, + placeholder_client, + initial_resume_session_id, + initial_history_replay_needed, + initial_defer_replay_sid_promotion, + project_and_start, + ): + self._explicit_effort_default_applied = True + self._explicit_effort_default_pending = False + if self.is_acp_runtime_backend and self.is_kiro_backend: + # After the window fence, not under it: every overlay write to this + # settings folder waits for open windows and gives up when its wait + # runs out, so these reads must not lengthen the hold. + await self._note_zero_tool_servers() await self._bind_unbound_effort() + effort_reasserted = await self._reassert_projected_effort() + if default_needs_push and effort_reasserted: + self._explicit_effort_default_applied = True + self._explicit_effort_default_pending = False await self._apply_initial_effort() diff --git a/src/kiro_crew/providers/acp_model_effort.py b/src/kiro_crew/providers/acp_model_effort.py index 7b8ebd02069..4fc80171510 100644 --- a/src/kiro_crew/providers/acp_model_effort.py +++ b/src/kiro_crew/providers/acp_model_effort.py @@ -19,6 +19,8 @@ class binds it by name, so it reads and writes the session's own state on the in import functools import logging import time +from collections.abc import Callable +from contextlib import AbstractContextManager, nullcontext from typing import TYPE_CHECKING from kiro_crew.acp.client import ( @@ -43,7 +45,11 @@ class binds it by name, so it reads and writes the session's own state on the in ) from kiro_crew.acp_backends import POLICY_ID_BY_BACKEND from kiro_crew.effort import EFFORT_LEVELS, model_supports_effort, resolve_effort_for_model -from kiro_crew.providers.acp_cli_overlay import _clear_cli_overlay_effort +from kiro_crew.providers.acp_cli_overlay import ( + _await_overlay_write, + _clear_cli_overlay_effort, + _hold_workspace_effort_overlay_fence, +) from kiro_crew.providers.base import LLMProvider from kiro_crew.workspace_cli_settings import CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS @@ -138,18 +144,20 @@ async def maybe_refresh_available_models( async def _bind_unbound_effort(self: AcpProvider) -> None: """Key a level requested with nothing pinned to the model now serving. - Runs on every (re)start once the session is ready and before the - initial effort push, because only then has the backend named the model - it chose. The requested level replaces one already stored for that - model: a live change on this provider moves the requested level with - it, so a stored level that differs can only be one a previous run left - in the workspace overlay, and keeping it would run the session at a - level the caller did not ask for. The kiro family reads effort from - the spawn-time overlay, which could not be keyed before the spawn, so - a level the overlay does not already hold is pushed live there through - :meth:`change_effort`, which also writes the overlay for the next - spawn. A level the serving model does not take is logged and left - unapplied. + Runs on every (re)start and every fresh warm-runtime conversation once + the session is ready and before the effort pushes, because only then + has the backend named the model it chose, and a fresh session can serve + another default than the last. The requested level replaces one already + stored for that model: a live change on this provider moves the + requested level with it, so a stored level that differs did not come + from this caller, and keeping it would run the session at a level the + caller did not ask for. The kiro family reads effort from the + spawn-time overlay, which could not be keyed before the spawn, so a + level not yet stored for that model is pushed live there through + :meth:`change_effort`, which also writes the overlay. A later start + projects as ``auto`` before its spawn, finds the level stored, and + :meth:`_reassert_projected_effort` pushes it after the spawn. A level + the serving model does not take is logged and left unapplied. """ level = self._unbound_effort if not level: @@ -173,9 +181,9 @@ def _restore_stored() -> None: # change_effort stores the level itself once the overlay # write and the live push have both landed, and rolls back # to the entry it found when either fails. That entry must - # be the level this session was spawned with (the overlay's, - # or none), so a push kiro-cli refuses leaves the map and - # the overlay naming the level the session actually runs. + # be the one this session started with (none on a first + # start), so a push kiro-cli refuses leaves the map as it + # was rather than naming a level the session does not run. _restore_stored() try: await self.change_effort(level) @@ -401,11 +409,11 @@ async def _set_effort_config_option(self: AcpProvider, level: str) -> None: async def change_effort(self: AcpProvider, level: str) -> bool: """Change effort live for the current model. Returns True on success. - Persists the slot override and (``ACP_BACKENDS_EFFORT_OVERLAY``) rewrites the - overlay so a later respawn keeps the level, then pushes the change to the running - session down the channel ``SessionCapabilities`` names: the ``/effort`` slash - command for ``ACP_BACKENDS_KIRO_SLASH_COMMANDS``, ``session/set_config_option`` - for ``ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION``. Returns False when the current + Persists the slot override and (``ACP_BACKENDS_EFFORT_OVERLAY``) projects the + overlay for a later respawn, then pushes the change to the running session down + the channel ``SessionCapabilities`` names: the ``/effort`` slash command for + ``ACP_BACKENDS_KIRO_SLASH_COMMANDS``, ``session/set_config_option`` for + ``ACP_BACKENDS_EFFORT_VIA_CONFIG_OPTION``. Returns False when the current model does not support effort, or when this harness is in neither set and so has no effort channel at all. """ @@ -465,8 +473,9 @@ async def change_effort(self: AcpProvider, level: str) -> bool: # critical section makes losing the lock a stuck holder rather than # routine contention, and waiting it out on the loop would freeze every # session. - if not await asyncio.to_thread( - self._apply_effort_overlay, timeout=CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS + if not await self._write_effort_overlay( + lambda: self._apply_effort_overlay(timeout=CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS), + CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS, ): if _prev is None: self._effort_per_model.pop(model, None) @@ -474,7 +483,9 @@ async def change_effort(self: AcpProvider, level: str) -> bool: self._effort_per_model[model] = _prev raise RuntimeError( f"could not persist effort {level!r} for {model!r}: the workspace " - "overlay is locked by another writer; try again" + "overlay is locked, a link or in a folder outside the work dir or on a " + "sensitive path, unreadable, not a JSON object, or past the 1 MiB read " + "ceiling or would be once rewritten; see the log" ) try: if via_config_option: @@ -486,30 +497,47 @@ async def change_effort(self: AcpProvider, level: str) -> bool: if _prev is None: self._effort_per_model.pop(model, None) if self._client.backend in ACP_BACKENDS_EFFORT_OVERLAY: - if not await asyncio.to_thread( - _clear_cli_overlay_effort, self._client._work_dir, model + if not await self._write_effort_overlay( + lambda: _clear_cli_overlay_effort(self._client._work_dir, model), + CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS, ): - # The overlay keeps a level the live push never applied, - # and construction re-seeds from it, so the next spawn - # adopts a level this rollback reports as undone. + # The overlay keeps the level the live push never applied. + # Keep it in the map too: it is the level this session was + # asked to run, so the next spawn projects it back rather + # than this rollback reporting an undo the file does not + # show. self._effort_per_model[model] = level logger.warning( "ACP effort rollback left the overlay set (model=%s effort=%s): " - "the workspace overlay lock was busy, so a respawn adopts it", + "the workspace overlay could not be rewritten because it is locked, " + "a link or in a folder outside the work dir or on a sensitive path, " + "unreadable, not a JSON object, or past the 1 MiB read ceiling or " + "would be once rewritten; a respawn adopts it", model, level, ) else: self._effort_per_model[model] = _prev - if not await asyncio.to_thread(self._apply_effort_overlay): - # Same divergence the branch above guards: the file keeps the - # level the live push never applied and construction re-seeds - # from it, so the map follows the file rather than reporting - # an undo the next spawn contradicts. + # The ACTION ceiling, as the write being undone took: the + # method's own default is the startup ceiling, under which a + # holder of the settings lock that outlasts a launch's wait + # keeps the level the live push never applied in the file. + if not await self._write_effort_overlay( + lambda: self._apply_effort_overlay(timeout=CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS), + CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS, + ): + # Same case as the branch above: the file keeps the level the + # live push never applied, and it is the level this session + # was asked to run, so the map follows the file and the next + # spawn projects it rather than this rollback reporting an + # undo the file does not show. self._effort_per_model[model] = level logger.warning( "ACP effort rollback left the overlay set (model=%s effort=%s): " - "the workspace overlay lock was busy, so a respawn adopts it", + "the workspace overlay could not be rewritten because it is locked, " + "a link or in a folder outside the work dir or on a sensitive path, " + "unreadable, not a JSON object, or past the 1 MiB read ceiling or " + "would be once rewritten; a respawn adopts it", model, level, ) @@ -551,6 +579,8 @@ async def reapply_live_effort(self: AcpProvider, level: str) -> bool: return bool(await self.change_effort(level)) # No override: re-resolve so a workspace default reaches the new model. A # False here only means there was no default to push, so nothing is stale. + # An automatic clear: the ownership guard stays on, so an entry the + # operator wrote for the model switched to is left where it is. await self.clear_effort() return True @@ -580,11 +610,29 @@ async def set_model(self: AcpProvider, model: str) -> None: logger.warning("Effort re-apply after set_model(%s) failed", model, exc_info=True) -async def clear_effort(self: AcpProvider) -> bool | None: +async def clear_effort( + self: AcpProvider, + *, + owned_only: bool = True, + fence_rewrite: Callable[[], AbstractContextManager[object]] = nullcontext, +) -> bool | None: """Clear the slot's effort override for the current model. - Drops the per-model override (and the kiro overlay entry) so the model - falls back to its own default. + Drops the per-model override. A clear without a workspace default falls + back to the model's own default after reset. With a workspace default, + the projected file value or the live post-spawn push carries that default. + + ``owned_only`` decides whose overlay entry may go. The default removes + only a value Kiro Crew recorded in the same cli.json document, so an + operator-authored entry survives every automatic clear: a live model + switch on a Default slot reaches here through ``reapply_live_effort`` + with nothing asked of that model's entry. Only the explicit dashboard + clear passes ``owned_only=False``, because there the operator has + directly asked for this model's entry to be removed, whoever wrote it. + That clear also passes the session manager's fence as ``fence_rewrite``: + the file is rewritten inside it, and a cancelled clear waits for its + write to settle, so no warm runtime that may have read the removed entry + is claimed after it. Returns True ONLY when a concrete default was applied LIVE to the running session (kiro family with a resolvable workspace default). @@ -594,11 +642,19 @@ async def clear_effort(self: AcpProvider) -> bool | None: - the config-option channel has no "reset to default" value (there is no level to push; only a respawn drops the override), and - kiro with no workspace default (the model's built-in default is only - re-applied on respawn once the overlay is cleared). + re-applied on respawn once the overlay is cleared), and + - the explicit clear with a workspace default whose live push fails after + the rewrite (only a respawn applies the default: kiro-cli reads it from + the file, KAS takes it as the start's ``effortLevel`` push). An automatic + clear re-raises that failure instead, because its callers read a raise + as the live re-apply failing. Without this, the running session would silently keep its prior effort while the UI shows "default". - Returns None when NOTHING changed because the workspace overlay was busy: - neither the file nor this map, so the caller commits no slot value and + Returns None when NOTHING changed because the workspace overlay cannot be + rewritten: it is locked, a link or in a folder outside the work dir or on a + sensitive path, unreadable, not a JSON object, or past the 1 MiB read ceiling + or would be once rewritten. + Neither the file nor this map changes, so the caller commits no slot value and resets nothing. """ model = self._effort_model @@ -622,48 +678,93 @@ async def clear_effort(self: AcpProvider) -> bool | None: # the harness that reads it and ask for a reset. level = self._resolve_effort() # workspace default, or None if level: - # Persist before pushing live, as change_effort does: the FILE is what - # a respawn reads, so pushing over a write that did not persist reports - # a default the next spawn replaces with the level just cleared. - if not await asyncio.to_thread( - self._apply_effort_overlay, timeout=CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS - ): + # Explicit Default replaces the model's effort entries and projects + # the workspace default in one locked atomic write. Automatic clears + # retain the ownership guard and carry the resolved default live. A + # harness that never reads the file takes the default live alone. + rewritten: object = True + if self._client.backend in ACP_BACKENDS_EFFORT_OVERLAY: + async with _hold_workspace_effort_overlay_fence( + self._client._work_dir, "write", CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS + ) as acquired: + if acquired: + with fence_rewrite(): + rewritten = await _await_overlay_write( + lambda: self._apply_effort_overlay( + timeout=CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS, + replace_unowned=not owned_only, + ) + ) + else: + rewritten = False + if not rewritten: if cleared is not None: self._effort_per_model[model] = cleared logger.warning( "ACP effort NOT cleared to workspace default %s (model=%s): the " - "workspace overlay was busy, so nothing was pushed live; clear " - "again once the concurrent writer finishes", + "workspace overlay cannot be rewritten because it is locked, a link or " + "in a folder outside the work dir or on a sensitive path, unreadable, " + "not a JSON object, or past the 1 MiB read ceiling or would be once " + "rewritten; nothing was pushed live", level, model, ) return None - # Same channel choice as change_effort: KAS takes the level as a - # config option, not the kiro-cli slash command. - if self.capabilities.effort_via_config_option: - await self._set_effort_config_option(level) - else: - await self._client.send_command("/effort", args={"level": level}) + try: + # Same channel choice as change_effort: KAS takes the level as a + # config option, not the kiro-cli slash command. + if self.capabilities.effort_via_config_option: + await self._set_effort_config_option(level) + else: + await self._client.send_command("/effort", args={"level": level}) + except Exception: + if owned_only: + raise + # The explicit clear has landed, so a reset carries the default to + # the session: kiro-cli reads it from the rewritten file and KAS + # takes it as the start's ``effortLevel`` push. Raising would make + # its caller carry a removal that the file already holds. + logger.warning( + "ACP effort cleared to workspace default %s (kiro), but the live push " + "failed; session reset needed", + level, + exc_info=True, + ) + return False logger.info("ACP effort cleared to workspace default %s (kiro)", level) return True # No default to push live — clear the overlay and let the caller reset # so kiro respawns at the model's built-in default. A harness that never # reads the file has no entry of its own there, and its cold start reads none. - if self._client.backend in ACP_BACKENDS_EFFORT_OVERLAY and not await asyncio.to_thread( - _clear_cli_overlay_effort, self._client._work_dir, model - ): + rewritten = True + if self._client.backend in ACP_BACKENDS_EFFORT_OVERLAY: + async with _hold_workspace_effort_overlay_fence( + self._client._work_dir, "write", CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS + ) as acquired: + if acquired: + with fence_rewrite(): + rewritten = await _await_overlay_write( + lambda: _clear_cli_overlay_effort( + self._client._work_dir, model, owned_only=owned_only + ) + ) + else: + rewritten = False + if not rewritten: # NOTHING changed: not the file, and not this map once the entry - # goes back. Neither bool can say that -- False resets (and the reset - # re-reads the same level), True reports a live update, and BOTH make + # goes back. Neither bool can say that -- False resets (and the + # respawn meets the same held lock, so it spawns into the same + # level), True reports a live update, and BOTH make # the handler commit the cleared slot value, so the UI would read # "default" over an overlay that still holds the level. None is the # third outcome, which the handler answers with a retryable 409. if cleared is not None: self._effort_per_model[model] = cleared logger.warning( - "ACP effort NOT cleared (model=%s): the workspace overlay was busy or " - "unreadable, so the file still holds it and the running session keeps " - "it; clear again once the concurrent writer finishes", + "ACP effort NOT cleared (model=%s): the workspace overlay cannot be rewritten " + "because it is locked, a link or in a folder outside the work dir or on a " + "sensitive path, unreadable, or past the 1 MiB read ceiling or would be once " + "rewritten; the file still holds it and the running session keeps it", model, ) return None @@ -687,8 +788,10 @@ async def _apply_initial_effort(self: AcpProvider) -> None: ``session/set_config_option`` is a fact the table already holds, and "runs on the shared runtime" is a different fact. The kiro family is outside the channel set because it reads effort from the spawn-time - cli.json overlay instead, so it still skips the push; KAS is the - exception, since it reads no overlay and is a member; opencode is + cli.json overlay instead, so it still skips the push here; its own + post-spawn push is :meth:`_reassert_projected_effort`, on the + ``/effort`` channel. KAS is the exception, since it reads no overlay + and is a member; opencode is outside it because its ``session/new`` advertises no ``effort`` option at all. Reading the runtime answer here instead drops a codex session's configured effort as soon as the preview switch puts codex on the @@ -716,3 +819,53 @@ async def _apply_initial_effort(self: AcpProvider) -> None: level, exc_info=True, ) + + +async def _reassert_projected_effort(self: AcpProvider) -> bool: + """Push this session's level live after spawn and report whether it took. + + Only the harnesses that read the overlay and take effort over ``/effort`` + (``capabilities.effort_via_slash_command``) need this. Kiro Crew writes no + overlay for KAS, which reads none; it takes its level as the ``effortLevel`` + option that :meth:`_apply_initial_effort` pushes. The overlay is one file + per work dir, projected before the spawn and read by kiro-cli at the + spawn. Between the two, another session + in the same work dir can project its own Default and remove this + session's entry: an unpinned session removes every Kiro Crew entry, a + session on the same model removes this model's, and the warm pool + constructs such a session in the dashboard's own work dir on every fill + and every replenish. This session cannot stop a peer from projecting, so + it pushes its level through the same ``/effort`` channel a live change + uses, and what it runs is its pick whatever the file said at the read. + + A session that resolves no level pushes nothing. This includes a Default + session without a workspace default, ``auto``, or a harness that does not + support effort. A Default session that inherits a workspace default pushes + that level like a pick. The level is read under ``_effort_model``, so an + unpinned session pushes the level :meth:`_bind_unbound_effort` stored + for the model it serves. Kiro-cli has no live "model default" to push to, + so the Default side of that same race stays with the overlay. A rejected + push does not break session start, but returns False so an explicit Default + remains pending. + """ + if not self.capabilities.effort_via_slash_command: + return True + model = self._effort_model + if not model or model == DEFAULT_MODEL or not self.supports_effort(): + return True + level = self._resolve_effort() + if not level: + return True + try: + await self._client.send_command("/effort", args={"level": level}) + logger.debug("ACP effort reasserted after spawn: model=%s effort=%s", model, level) + except Exception: + logger.warning( + "ACP effort reassert after spawn failed (model=%s effort=%s): the session " + "runs whatever level kiro-cli read from the overlay at spawn", + model, + level, + exc_info=True, + ) + return False + return True diff --git a/src/kiro_crew/providers/base.py b/src/kiro_crew/providers/base.py index 3e3f8295ec2..64ff56ebd69 100644 --- a/src/kiro_crew/providers/base.py +++ b/src/kiro_crew/providers/base.py @@ -9,6 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterator, Callable +from contextlib import AbstractContextManager from functools import cached_property from typing import TYPE_CHECKING, AsyncContextManager, Literal, Protocol, runtime_checkable @@ -620,6 +621,32 @@ def has_active_turn(self) -> bool: """True if a prompt is in flight and not yet cancelled. Default False.""" return False + def arm_explicit_effort_default( + self, fence_rewrite: Callable[[], AbstractContextManager[object]] + ) -> None: + """Arm a pending explicit Default for this provider's next start. + + Called by the cold start after it reserves the session map's one-shot + ``explicit_effort_default`` without clearing its durable flag, right + before ``start()``. The start rewrites the file inside + ``fence_rewrite``, which keeps warm runtimes that may have read the + removed entry from being claimed. Default: a provider that reads no + workspace effort overlay has nothing to arm. + """ + + @property + def explicit_effort_default_applied(self) -> bool: + """Whether an armed explicit Default applied when this provider started. + + The cold start reads this after ``start()`` and clears the session map's + flag when it is True. Default True: a provider that reads no workspace + effort overlay has nothing for that start to remove. ``AcpProvider`` + reports True once its start has applied the Default; on a harness that + reads the overlay that waits for the backend to report the model it + served, so a pin it did not serve is finished for the model it did. + """ + return True + def has_unfinished_turn(self) -> bool: """True if a native turn has not reached its done boundary, independent of cancel state (drives the shutdown drain). Default False.""" diff --git a/src/kiro_crew/security_posture.py b/src/kiro_crew/security_posture.py index cd0f63ee13e..4dc1141819a 100644 --- a/src/kiro_crew/security_posture.py +++ b/src/kiro_crew/security_posture.py @@ -508,6 +508,11 @@ "acp/runtime_host_requests.py", "acp/runtime_liveness.py", "acp/runtime_session_registry.py", + # Redacts a model key read from a work directory's `cli.json` before it + # is bounded into the unowned-effort WARNING and the removal INFO line in + # the gateway log. A local log line, like the one above, not an egress + # boundary. + "providers/acp_cli_overlay.py", "acp/session_handle_compaction.py", "acp/session_handle_dispatch.py", "acp/session_handle_kas.py", diff --git a/src/kiro_crew/session.py b/src/kiro_crew/session.py index af259d339e8..353280aef79 100644 --- a/src/kiro_crew/session.py +++ b/src/kiro_crew/session.py @@ -663,6 +663,8 @@ def _lifecycle_deps(self) -> SessionLifecycleDeps: _record_pool_decision = _runtimes._record_pool_decision _claim_from_pool = _runtimes._claim_from_pool _drain_and_claim = _runtimes._drain_and_claim + _take_claim_spawn_time = _runtimes._take_claim_spawn_time + _may_predate_effort_overlay_rewrite = _runtimes._may_predate_effort_overlay_rewrite _schedule_replenish = _runtimes._schedule_replenish _pool_pids = _runtimes._pool_pids _in_flight_pids = _runtimes._in_flight_pids @@ -674,6 +676,7 @@ def _lifecycle_deps(self) -> SessionLifecycleDeps: recycle_heartbeat = _runtimes.recycle_heartbeat _retire_kiro_warm_pool = _runtimes._retire_kiro_warm_pool _mark_identity_epoch = _runtimes._mark_identity_epoch + fence_effort_overlay_rewrite = _runtimes.fence_effort_overlay_rewrite drain_warm_pool = _runtimes.drain_warm_pool warm_providers = _runtimes.warm_providers mark_mcp_config_changed = _runtimes.mark_mcp_config_changed @@ -700,6 +703,13 @@ def _lifecycle_deps(self) -> SessionLifecycleDeps: consume_replay_suppression = _turn_context.consume_replay_suppression _consume_persisted_replay_suppression = _turn_context._consume_persisted_replay_suppression suppress_replay_persistently = _turn_context.suppress_replay_persistently + set_explicit_effort_default = _turn_context.set_explicit_effort_default + effort_basis_locked = _turn_context.effort_basis_locked + _effort_intent_writes_in_flight = _turn_context._effort_intent_writes_in_flight + effort_intent_write = _turn_context.effort_intent_write + wait_for_effort_intent_writes = _turn_context.wait_for_effort_intent_writes + hold_explicit_effort_default = _turn_context.hold_explicit_effort_default + explicit_effort_default_pending = _turn_context.explicit_effort_default_pending _compaction_gate_decision = _turn_context._compaction_gate_decision _trigger_compaction = _turn_context._trigger_compaction _compact_session = _turn_context._compact_session @@ -1175,6 +1185,7 @@ def mark_first_turn_history_owed(self, key: str) -> bool: ) from kiro_crew.session_parts.record import ( # noqa: F401 StopOutcome, + _EffortIntentWrites, ) # The owners annotate with these names, which the agent-SDK boundary lets only this diff --git a/src/kiro_crew/session_allocation.py b/src/kiro_crew/session_allocation.py index b7d0f725d74..b37296b4141 100644 --- a/src/kiro_crew/session_allocation.py +++ b/src/kiro_crew/session_allocation.py @@ -181,6 +181,9 @@ class SessionRegistryState: #: still cold-starting. Reads like the set it replaces. starting_pids: PidRefcount = field(default_factory=PidRefcount) allocation_reservations: dict[str, set[object]] = field(default_factory=dict) + explicit_effort_default_reservations: dict[str, tuple[asyncio.Task[Any], asyncio.Event]] = ( + field(default_factory=dict) + ) inbound_callback_reservations: set[object] = field(default_factory=set) ownership_generations: dict[str, int] = field(default_factory=dict) subagent_runtimes: dict[str, Any] = field(default_factory=dict) @@ -218,6 +221,12 @@ class SessionRegistryState: #: ``spawn_in_flight``; a token leaves at registration (published, or a #: won race) or with its reservation. spawning_reservations: set[object] = field(default_factory=set) + #: Reservation tokens of calls on the cold-start path: marked when + #: ``get_or_create`` finds no live session to claim, before the start reads + #: the key's effort basis, and removed with the reservation. A claim waiting + #: on a live session's lease is never here: it reads no basis, so an effort + #: pick it overlaps has nothing to race. Read by ``has_cold_start_reservation``. + cold_start_reservations: set[object] = field(default_factory=set) #: Per fenced key, a receipt for every start the fence caught past its spawn #: door and whose reservation was then removed WHILE the fence was still up: #: the start returned during the ending caller's passes, its registration @@ -266,6 +275,18 @@ def _fold_key(self, key: str) -> str: ... def _bg_backend_supports_runtime(self) -> bool: ... + def explicit_effort_default_pending(self, key: str) -> bool: ... + + def set_explicit_effort_default(self, key: str, pending: bool) -> bool: ... + + def hold_explicit_effort_default(self, key: str) -> contextlib.AbstractContextManager[None]: ... + + async def wait_for_effort_intent_writes(self, key: str) -> None: ... + + def fence_effort_overlay_rewrite(self) -> contextlib.AbstractContextManager[None]: ... + + async def aflush(self) -> None: ... + async def await_replay_gap(self, key: str) -> None: ... def absorb_orphaned_release(self, key: str) -> bool: ... @@ -314,12 +335,18 @@ def _parent_runtime_kwargs(self, parent_session_key: str) -> dict[str, Any]: ... async def _drain_and_claim(self, agent: str | None) -> LLMProvider | None: ... + def _take_claim_spawn_time(self, provider: LLMProvider) -> float | None: ... + + def _may_predate_effort_overlay_rewrite(self, spawn_time: float) -> bool: ... + def _record_pool_decision(self, decision: str, key: str) -> None: ... def _schedule_replenish(self) -> None: ... def _dispatch_hard_kill(self, provider: LLMProvider) -> None: ... + async def _discard_pool_provider(self, provider: LLMProvider, context: str) -> None: ... + def _resolve_agent_model(self, agent: str) -> str: ... def _ensure_cleanup_task(self) -> None: ... @@ -513,6 +540,15 @@ def has_allocation_reservation(self, key: str) -> bool: """Return whether a folded alias has an allocation/claim in flight.""" return bool(self._allocation_reservations.get(self._owner._fold_key(key))) + def has_cold_start_reservation(self, key: str) -> bool: + """Return whether a folded alias has a cold start in flight. + + Marked from before the start's effort basis read until registration. + """ + cold = self.state.cold_start_reservations + reserved = self._allocation_reservations.get(self._owner._fold_key(key), ()) + return bool(cold) and any(token in cold for token in reserved) + def spawn_in_flight(self, key: str) -> str | None: """Why a cold start under *key* is a process the ending caller must name, or None. @@ -1078,6 +1114,44 @@ def allocation_predecessor(self, key: str) -> str: def mapped_session_keys(self) -> frozenset[str]: return frozenset(self._owner._session_map.mapped_sids_by_key()) + def _reserve_explicit_effort_default(self, key: str) -> bool: + """Reserve *key*'s pending explicit Default through registration. + + A competing allocation waits on the reservation's completion event before + constructing or starting a provider. The durable flag stays armed until the + reserving provider's projection applies it, so a process death before that + clear is saved leaves the next cold start to apply the Default again. The + reservation is process-local and canonical-keyed so two spellings of one + conversation cannot both arm, and owned by the allocation task so a + same-task retry keeps it. Call it from a running allocation task. + """ + reservation_key = self._deps.canonical_key(key) + reservations = self.state.explicit_effort_default_reservations + current = reservations.get(reservation_key) + if current is not None: + return current[0] is asyncio.current_task() + if not self._owner.explicit_effort_default_pending(key): + return False + allocation = asyncio.current_task() + if allocation is None: # pragma: no cover - called only from allocation tasks + raise RuntimeError("explicit Default reservation has no allocation task") + reservations[reservation_key] = (allocation, asyncio.Event()) + return True + + def _release_explicit_effort_default(self, key: str) -> None: + """Release an owned explicit Default reservation and wake its waiters. + + Only the owning task releases, so a waiter's exit and a nested same-task + retry's exit cannot drop a reservation another frame still relies on. + """ + reservation_key = self._deps.canonical_key(key) + reservations = self.state.explicit_effort_default_reservations + current = reservations.get(reservation_key) + if current is None or current[0] is not asyncio.current_task(): + return + del reservations[reservation_key] + current[1].set() + def seed_conversation( self, key: str, @@ -1451,6 +1525,7 @@ def _resolve_agent_model(self, agent: str) -> str: "START_QUEUE_LOG_MIN_MS", "SessionBusyError", "SpeculativeResumeRefused", + "_WarmPoolEffortRewriteDiscard", "_collect_parent_runtime_kwargs", "acquire_session_lease", "bound_profile_env", @@ -1500,6 +1575,7 @@ def _resolve_agent_model(self, agent: str) -> str: from kiro_crew.session_allocation_parts.errors import ( # noqa: F401 SessionBusyError, SpeculativeResumeRefused, + _WarmPoolEffortRewriteDiscard, ) from kiro_crew.start_priority import ( # noqa: F401 START_QUEUE_COLD_START, diff --git a/src/kiro_crew/session_allocation_parts/errors.py b/src/kiro_crew/session_allocation_parts/errors.py index 6b0dbf0889a..6ecea9edc3c 100644 --- a/src/kiro_crew/session_allocation_parts/errors.py +++ b/src/kiro_crew/session_allocation_parts/errors.py @@ -10,6 +10,11 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kiro_crew.session_allocation import LLMProvider + class SessionClosingError(RuntimeError): """A turn was requested after manager shutdown began.""" @@ -44,6 +49,13 @@ class SessionBusyError(RuntimeError): """A caller requested an immediate turn claim while the session was held.""" +class _WarmPoolEffortRewriteDiscard(RuntimeError): + """A claimed warm runtime crossed an explicit-Default rewrite before registration.""" + + def __init__(self, provider: LLMProvider) -> None: + self.provider = provider + + #: How long a claim or cold start waits at the door of ``get_or_create`` for a #: key's ending fence to lift before it is refused (:class:`SessionEndingError`). #: An independent caller-refusal bound, not a multiple of the fence's length: a @@ -70,3 +82,4 @@ class SpeculativeResumeRefused(RuntimeError): SessionEndingError.__module__ = "kiro_crew.session_allocation" SessionBusyError.__module__ = "kiro_crew.session_allocation" SpeculativeResumeRefused.__module__ = "kiro_crew.session_allocation" +_WarmPoolEffortRewriteDiscard.__module__ = "kiro_crew.session_allocation" diff --git a/src/kiro_crew/session_allocation_parts/get_or_create.py b/src/kiro_crew/session_allocation_parts/get_or_create.py index 811165102a9..21625173310 100644 --- a/src/kiro_crew/session_allocation_parts/get_or_create.py +++ b/src/kiro_crew/session_allocation_parts/get_or_create.py @@ -19,9 +19,9 @@ Instance fields read: ``_closing``, ``_deps``, ``_lock``, ``_owner``, ``_start_sem``, ``state``. -Through ``state`` it also changes the ending fence's and the capability records in place -(``spawning_reservations``, ``invalidated_reservations``, ``refused_spawns``, -``capability_failures``) and reads ``ending_keys``. +Through ``state`` it also changes the ending fence's, the cold-start and the capability +records in place (``spawning_reservations``, ``invalidated_reservations``, ``refused_spawns``, +``cold_start_reservations``, ``capability_failures``) and reads ``ending_keys``. """ from __future__ import annotations @@ -53,6 +53,7 @@ SessionClosingError, SessionEndingError, SpeculativeResumeRefused, + _WarmPoolEffortRewriteDiscard, ) from kiro_crew.session_lifecycle import adopt_parked_queue from kiro_crew.start_priority import ( @@ -202,6 +203,27 @@ async def _crew_pins_effort( return False +async def _save_applied_explicit_effort_default_clear( + service: SessionAllocationService, key: str +) -> None: + """Clear and save an explicit effort Default the started provider just applied. + + A failed save never fails the start: ``aflush`` restores the dirty mark, so + the map's next successful save retries this already-applied Default's clear. + """ + owner = service._owner + owner.set_explicit_effort_default(key, False) + try: + await owner.aflush() + except Exception: + service._deps.logger.warning( + "Could not save the explicit effort Default clear for %s; " + "the Default already ran and the map's next save retries its clear", + key, + exc_info=True, + ) + + def _dispatch_hard_kill(self: SessionAllocationService, provider: LLMProvider) -> None: """Dispatch blocking provider teardown away from the event-loop thread.""" kill = self._deps.get_sync_kill_provider() @@ -231,6 +253,7 @@ def _remove_reservation_now(self: SessionAllocationService, key: str, token: obj if token in self.state.spawning_reservations and key in self.state.ending_keys: self.state.refused_spawns[key] = self.state.refused_spawns.get(key, 0) + 1 self.state.spawning_reservations.discard(token) + self.state.cold_start_reservations.discard(token) fence = self.state.ending_keys.get(key) if fence is not None: fence.discard(token) @@ -345,22 +368,25 @@ async def get_or_create( fence_deadline = await self.wait_for_ending_fence(reserved_key, fence_deadline) continue try: - result = await self._get_or_create_impl( - reserved_key, - agent=agent, - channel_id=channel_id, - approval_policy=approval_policy, - model=model, - cwd=cwd, - extra_env=extra_env, - speculative=speculative, - speculative_resume=speculative_resume, - wait_if_busy=wait_if_busy, - _won_race_retries=_won_race_retries, - _reservation=token, - start_priority=start_priority, - **extra_factory_kwargs, - ) + try: + result = await self._get_or_create_impl( + reserved_key, + agent=agent, + channel_id=channel_id, + approval_policy=approval_policy, + model=model, + cwd=cwd, + extra_env=extra_env, + speculative=speculative, + speculative_resume=speculative_resume, + wait_if_busy=wait_if_busy, + _won_race_retries=_won_race_retries, + _reservation=token, + start_priority=start_priority, + **extra_factory_kwargs, + ) + finally: + self._release_explicit_effort_default(reserved_key) except SessionEndingError: # The key was fenced while this allocation was in flight: a door # of the body refused it -- at registration, with the provider @@ -372,9 +398,25 @@ async def get_or_create( await self._remove_reservation_cancellation_drained(reserved_key, token) fence_deadline = await self.wait_for_ending_fence(reserved_key, fence_deadline) continue + except _WarmPoolEffortRewriteDiscard as discarded: + try: + await self._owner._discard_pool_provider( + discarded.provider, "Warm pool effort registration discard" + ) + finally: + await self._remove_reservation_cancellation_drained(reserved_key, token) + continue except BaseException: await self._remove_reservation_cancellation_drained(reserved_key, token) raise + if isinstance(result, asyncio.Event): + # Another same-key start holds the pending Default. Drop this token so + # the owner is not blocked by it, wait for the owner to register or + # fail, then retry: the retry reuses the owner's session or reserves + # the Default itself. + await self._remove_reservation_cancellation_drained(reserved_key, token) + await result.wait() + continue self._remove_reservation_now(reserved_key, token) # This task now holds the key's live permit. If it reset its previous # session on this key (a replay), the release it will make is for THIS @@ -509,6 +551,11 @@ async def _get_or_create_impl( if not owner._provider_factory: raise RuntimeError("No provider factory configured") factory = owner._provider_factory + if _reservation is not None: + # From here this call reads the key's effort basis and + # publishes a session built on it, so an effort pick that + # would change the basis is refused until registration. + self.state.cold_start_reservations.add(_reservation) finally: if stale_provider is not None: if stale_session is not None: @@ -661,6 +708,46 @@ def resolve_model() -> str | None: owner._pool_cwd, ) provider_switched = False + # A pending explicit Default (the session map's one-shot flag) needs a + # cold start: a warm process read cli.json at its own spawn, and Default + # cannot be pushed to a live session. Read once, here: this call's + # reservation is marked a cold start (``cold_start_reservations``) from + # before this read until the session is registered, and the effort + # handler refuses a pick that would change the flag while it is + # (``SessionManager.effort_basis_locked``), so what this start reads is + # the basis the session it publishes runs on. A pick that passed that + # check before the mark may still be saving: wait for its write to settle first, so the value + # read is one a save kept (or the one a failed save put back), never + # one no save will keep. The count can only fall while this waits, + # because every later pick is refused or has nothing to write. A + # cancellation here propagates; nothing was read or written. + await owner.wait_for_effort_intent_writes(key) + explicit_effort_default = owner.explicit_effort_default_pending(key) + if explicit_effort_default and extra_factory_kwargs.get("reasoning_effort_override"): + # A start on a level its caller chose runs that level and does not + # apply or clear the pending Default. The flag is pending because + # the key's latest effort action was a Default pick (a level pick + # through the effort handler, or a level session_set_model commits + # at the turn start, clears it), and this override is not a newer + # one: a slot's level read before that pick landed, an alias slot's + # own level, or a caller's pin. The flag stays for the next start + # that takes no override, which applies and clears it. The start + # keeps the pool: the override is pushed to the claimed process + # below. + explicit_effort_default = False + if explicit_effort_default: + if _reservation is None: # pragma: no cover - private implementation contract + raise RuntimeError("explicit Default allocation has no reservation token") + # Reserve before the pool decision and any provider exists, so a second + # start that read the same pending Default never runs unarmed beside it. + # Its Event goes back to the outer loop, which waits on it and retries. + explicit_effort_default = self._reserve_explicit_effort_default(key) + if not explicit_effort_default: + current = self.state.explicit_effort_default_reservations.get( + self._deps.canonical_key(key) + ) + if current is not None: + return cast(Any, current[1]) cwd_blocks_pool = bool(cwd and cwd != owner._pool_cwd) if not owner._pool_size: pool_decision = "disabled" @@ -707,6 +794,8 @@ def resolve_model() -> str | None: # ``""`` is a Kiro request, and under a non-Kiro default the pooled # child is on the wrong backend for it too. pool_decision = "bypass_backend_override" + elif explicit_effort_default: + pool_decision = "bypass_effort" elif await _session_runs_remotely(key): # A pooled child is a LOCAL engine: claiming it would run a remote session here. pool_decision = "bypass_remote_environment" @@ -725,6 +814,7 @@ def resolve_model() -> str | None: pool_decision = "" pooled = None if pool_decision else await owner._drain_and_claim(agent) + claim_spawn_time = owner._take_claim_spawn_time(pooled) if pooled is not None else None if not pool_decision: pool_decision = "hit" if pooled is not None else "miss_empty" owner._record_pool_decision(pool_decision, key) @@ -987,21 +1077,33 @@ def resolve_claim_watchdog() -> tuple[str, object]: if _reservation is not None: self.state.spawning_reservations.add(_reservation) pre_spawn = await pre_spawn_identity(getattr(owner, "spawn_identity_reader", None)) + # The reservation only serializes starts; a Default revoked since + # it was taken must not authorize a rewrite. No await between this + # read and the arm below. + if explicit_effort_default and not owner.explicit_effort_default_pending(key): + explicit_effort_default = False + if explicit_effort_default: + provider.arm_explicit_effort_default(owner.fence_effort_overlay_rewrite) await provider.start() + # Shield the published PID from the orphan sweep before the + # Default clear's flush or the stamp read below can suspend. + starting_pid = spawn_pid(provider) + if starting_pid is not None: + self._starting_pids.add(starting_pid) + if explicit_effort_default and provider.explicit_effort_default_applied: + await _save_applied_explicit_effort_default_clear(self, key) except (asyncio.CancelledError, Exception): + if explicit_effort_default and provider.explicit_effort_default_applied: + # The projection ran before the failure. Keep its clear in + # memory for the deferred map flush; never await here, + # because this path also handles cancellation. + owner.set_explicit_effort_default(key, False) if preparation.revision: self._remember_capability_failure(key, preparation) owner._dispatch_hard_kill(provider) + if starting_pid is not None: + self._starting_pids.discard(starting_pid) raise - # start() has published the PID, and the stamp read below is a - # real suspension point before registry ownership becomes - # visible in the lock section further down -- shield the PID - # from the periodic orphan sweep for that whole span (on - # Windows the sweep has no age grace, so an unshielded child - # caught mid-stamp would be killed as an orphan). - starting_pid = spawn_pid(provider) - if starting_pid is not None: - self._starting_pids.add(starting_pid) # Record which account the store held as this child spawned # (first-stamp-wins, so a warm-pool claim keeps its fill-time # stamp). Best-effort: an unstamped child keeps the @@ -1073,6 +1175,20 @@ def resolve_claim_watchdog() -> tuple[str, object]: # a start an ending caller must be told about. self.state.spawning_reservations.discard(_reservation) + if ( + pooled is not None + and claim_spawn_time is not None + and owner._may_predate_effort_overlay_rewrite(claim_spawn_time) + ): + # The post-claim rekey and model work await off the loop, so + # another session can rewrite cli.json after the first fence + # check. Refuse before this provider becomes a live session. + self._deps.logger.info( + "Warm pool: explicit effort Default rewrote cli.json after claim, " + "discarding before registration" + ) + raise _WarmPoolEffortRewriteDiscard(provider) + existing = self._sessions.get(key) recycling = existing is not None and owner._recycling.get(key) is existing if existing is not None and not recycling: @@ -1216,6 +1332,8 @@ def resolve_claim_watchdog() -> tuple[str, object]: session.turn_owner = asyncio.current_task() self._deps.inc_session_created() result = (provider, True, resumed) + except _WarmPoolEffortRewriteDiscard: + raise except BaseException: if preparation.revision: self._remember_capability_failure(key, preparation) @@ -1246,6 +1364,9 @@ def resolve_claim_watchdog() -> tuple[str, object]: key, exc_info=True, ) + # A race loser claims the winner's live session, which reads no basis + # and can wait a whole turn, so it does not lock the key's effort basis. + self.state.cold_start_reservations.discard(_reservation) if await owner._reacquire_and_validate( key, won_race_session, diff --git a/src/kiro_crew/session_map.py b/src/kiro_crew/session_map.py index 9fc048e7915..33e70a4a6d3 100644 --- a/src/kiro_crew/session_map.py +++ b/src/kiro_crew/session_map.py @@ -11,7 +11,8 @@ import logging import os # noqa: F401 import threading -from collections.abc import Callable +from collections.abc import Callable, Iterator +from contextlib import contextmanager from pathlib import Path from kiro_crew import session_map_links as _links @@ -137,6 +138,19 @@ def session_files_resumable(sid: str, provider: str = "") -> bool: #: touching the entry. Scoped to the sid rather than a bare flag, so a #: later mapping of a different sid can never inherit the verdict. UNLANDED_SID_FIELD = "unlanded_sid" +#: Set on a conversation whose operator explicitly picked Default when the pick +#: could not remove the model's workspace ``cli.json`` effort entry itself: no +#: session was live, its turn was in flight, the live clear raised, or its model +#: takes no effort. Only an explicit Default may remove an entry Kiro Crew did not +#: write, so without this the next cold start keeps such an entry and runs its +#: level while the slot shows Default. Persisted because the pick is often made +#: while no session exists, and a gateway restart before the next start must not +#: lose it. One-shot: cleared by the first cold start whose pre-spawn projection +#: applies it. +EXPLICIT_EFFORT_DEFAULT_FLAG = "explicit_effort_default" +#: The maximum number of durable explicit-Default rows. The privacy-key bound +#: applies too because both are persisted session keys. +EXPLICIT_EFFORT_DEFAULT_ROW_CAP = 1_000 # Highest explicit DM generation acknowledged before its first provider turn. # Stored on the stable bucket entry so repeated /new commands cost one integer, @@ -159,9 +173,14 @@ def session_files_resumable(sid: str, provider: str = "") -> bool: # exists for — clear the pointer, restart the gateway, reinstall — so the flag would # be decorative. The forever-row cost does not apply to it either: the flag is # ONE-SHOT, so the row it keeps alive is collectable again as soon as the first -# cold start consumes it. (The privacy flags below are retained on separate -# grounds and bounded by a count; their own comment says why.) -_DURABLE_FLAGS = frozenset({MIRROR_OPT_OUT_FLAG, SUPPRESS_REPLAY_FLAG}) +# cold start consumes it. ``EXPLICIT_EFFORT_DEFAULT_FLAG`` is durable on the same +# grounds: it is written for the key's next cold start, often while no session +# exists, and the first cold start that applies it clears it. Its retained rows are +# bounded by ``EXPLICIT_EFFORT_DEFAULT_ROW_CAP``. (The privacy flags below are +# retained on separate grounds and bounded by a count; their own comment says why.) +_DURABLE_FLAGS = frozenset( + {MIRROR_OPT_OUT_FLAG, SUPPRESS_REPLAY_FLAG, EXPLICIT_EFFORT_DEFAULT_FLAG} +) # The ``!temporary`` / ``!incognito`` privacy modes, spelled exactly as # ``messaging.privacy_mode`` names them (MODE_TEMPORARY / MODE_INCOGNITO; a test @@ -226,6 +245,19 @@ def __init__(self, key: str, reason: str) -> None: self.reason = reason +class EffortDefaultRowRefused(ValueError): + """The map refused to retain an explicit-Default row for *key*. + + ``reason`` is ``"limit"`` (``EXPLICIT_EFFORT_DEFAULT_ROW_CAP`` rows already + retained) or ``"key_too_long"`` (over ``PRIVACY_ROW_KEY_MAX``). + """ + + def __init__(self, key: str, reason: str) -> None: + super().__init__(f"explicit effort Default row refused for {key[:80]!r}: {reason}") + self.key = key + self.reason = reason + + # How long a deferred flush waits before serializing, so a burst of mutations # (a subagent wave calling ``set`` once per spawn) collapses into one write # instead of one write per mutation. Small on purpose: the window is also how @@ -235,12 +267,24 @@ def __init__(self, key: str, reason: str) -> None: _FLUSH_DEBOUNCE_SECS = 0.05 +def _explicit_effort_default_value_on(flags: dict) -> bool: + """True only when the persisted explicit-Default value is the boolean ``True``.""" + return flags.get(EXPLICIT_EFFORT_DEFAULT_FLAG) is True + + def _has_durable_flag(entry: dict) -> bool: """True iff *entry* carries a durable SETTING (:data:`_DURABLE_FLAGS`).""" flags = entry.get("flags") if not isinstance(flags, dict): return False - return any(flags.get(name) for name in _DURABLE_FLAGS) + return any( + ( + _explicit_effort_default_value_on(flags) + if name == EXPLICIT_EFFORT_DEFAULT_FLAG + else flags.get(name) + ) + for name in _DURABLE_FLAGS + ) def _privacy_flags_on(entry: dict) -> list[str]: @@ -251,6 +295,12 @@ def _privacy_flags_on(entry: dict) -> list[str]: return [name for name in _PRIVACY_STRICTNESS if flags.get(name)] +def _explicit_effort_default_on(entry: dict) -> bool: + """True iff *entry* carries the one-shot :data:`EXPLICIT_EFFORT_DEFAULT_FLAG`.""" + flags = entry.get("flags") + return isinstance(flags, dict) and _explicit_effort_default_value_on(flags) + + def _header_records_privacy_mode(key: str, flagged: list[str]) -> bool: """True iff *key*'s transcript header carries a mode at least as strict as *flagged*. @@ -320,6 +370,11 @@ def _survives_prune(entry: dict) -> bool: ) +def _blank_entry() -> dict: + """The entry :meth:`SessionMap._ensure_entry` creates for a key that has none.""" + return {"sid": "", "slack_thread_ts": None, "slack_channel_id": None} + + def _keeps_entry(entry: dict) -> bool: """The one predicate both stale paths ask: keep *entry* (clearing only its ``sid``)? @@ -549,6 +604,12 @@ def __init__(self) -> None: self._snapshot_seq = 0 self._written_seq = 0 self._io_lock = threading.Lock() + # Keys whose explicit-Default row a pick in flight has cleared and may put + # back, with how many holds each carries (see + # ``hold_explicit_effort_default``). They stay counted against + # ``EXPLICIT_EFFORT_DEFAULT_ROW_CAP`` beside the flagged rows. In memory + # only: a hold lasts one pick request, and a restart ends it. + self._explicit_effort_default_holds: dict[str, int] = {} self._load() async def _flush_async(self) -> None: @@ -1261,7 +1322,7 @@ def _ensure_entry(self, key: str) -> dict: """Return the entry for *key*, creating a blank one if absent.""" entry = self._data.get(key) if entry is None: - entry = {"sid": "", "slack_thread_ts": None, "slack_channel_id": None} + entry = _blank_entry() self._data[key] = entry return entry @@ -1287,6 +1348,14 @@ def set_flag(self, key: str, flag: str, value: bool) -> None: (``privacy_mode.reserve``) applies the flag ahead of that step and takes it back if the step fails, rather than pre-checking a count another writer can change under it. + + Setting ``EXPLICIT_EFFORT_DEFAULT_FLAG`` likewise admits a durable row + only while its key and retained-row count fit their bounds. The count + includes every key :meth:`hold_explicit_effort_default` holds, and a held + key arms into the row its hold kept counted, so it skips the count but not + the key bound. Clearing or setting an already flagged row is never refused. + Clearing it on a row that holds nothing else removes the row, so the flag + never leaves behind a row its bound does not count. """ key = canonical_key(key) if ( @@ -1299,6 +1368,19 @@ def set_flag(self, key: str, flag: str, value: bool) -> None: retained = sum(1 for entry in self._data.values() if _privacy_flags_on(entry)) if retained >= PRIVACY_ROW_CAP: raise PrivacyRowRefused(key, "limit") + existing = self._data.get(key) + if ( + value + and flag == EXPLICIT_EFFORT_DEFAULT_FLAG + and not (existing and _explicit_effort_default_on(existing)) + ): + if len(key) > PRIVACY_ROW_KEY_MAX: + raise EffortDefaultRowRefused(key, "key_too_long") + if ( + key not in self._explicit_effort_default_holds + and self._explicit_effort_default_rows_counted() >= EXPLICIT_EFFORT_DEFAULT_ROW_CAP + ): + raise EffortDefaultRowRefused(key, "limit") if value: entry = self._ensure_entry(key) else: @@ -1308,7 +1390,9 @@ def set_flag(self, key: str, flag: str, value: bool) -> None: if not existing: return entry = existing - flags = entry.get("flags") or {} + flags = entry.get("flags") + if not isinstance(flags, dict): + flags = {} if value: flags[flag] = True else: @@ -1317,15 +1401,81 @@ def set_flag(self, key: str, flag: str, value: bool) -> None: entry["flags"] = flags else: entry.pop("flags", None) + if not value and flag == EXPLICIT_EFFORT_DEFAULT_FLAG and entry == _blank_entry(): + # A row made only to carry the flag leaves with it: kept, it would + # be retained state that the explicit-Default bound does not count. + self._remove_entry(key) + return self._save() + @_guarded + def _explicit_effort_default_rows_counted(self) -> int: + """The rows ``EXPLICIT_EFFORT_DEFAULT_ROW_CAP`` bounds: flagged, and held but not flagged.""" + flagged = sum(1 for entry in self._data.values() if _explicit_effort_default_on(entry)) + held_unflagged = sum( + 1 + for key in self._explicit_effort_default_holds + if not _explicit_effort_default_on(self._data.get(key) or {}) + ) + return flagged + held_unflagged + + def explicit_effort_default_held(self, key: str) -> bool: + """True while :meth:`hold_explicit_effort_default` holds *key*'s row.""" + return canonical_key(key) in self._explicit_effort_default_holds + + @contextmanager + def hold_explicit_effort_default(self, *keys: str) -> Iterator[None]: + """Keep each counted explicit-Default row among *keys* counted until the block exits. + + A pick that clears a key's explicit Default takes this first, so the row + stays counted until the request ends. Putting the flag back is never + refused, and no other key's pick can take the row meanwhile. Only a key + already counted (flagged, or held by another block) is held, so a hold + never adds a row and the map never retains more rows than the bound. The + map lock is held while the holds change, never across the block, which + may await. + """ + held = self._take_explicit_effort_default_holds(keys) + try: + yield + finally: + self._release_explicit_effort_default_holds(held) + + @_guarded + def _take_explicit_effort_default_holds(self, keys: tuple[str, ...]) -> tuple[str, ...]: + """Hold each distinct counted key among *keys*; return the keys held.""" + held = [] + for key in dict.fromkeys(canonical_key(key) for key in keys): + if key in self._explicit_effort_default_holds or _explicit_effort_default_on( + self._data.get(key) or {} + ): + self._explicit_effort_default_holds[key] = ( + self._explicit_effort_default_holds.get(key, 0) + 1 + ) + held.append(key) + return tuple(held) + + @_guarded + def _release_explicit_effort_default_holds(self, held: tuple[str, ...]) -> None: + """Release one hold on each key in *held*.""" + for key in held: + remaining = self._explicit_effort_default_holds[key] - 1 + if remaining: + self._explicit_effort_default_holds[key] = remaining + else: + del self._explicit_effort_default_holds[key] + def get_flag(self, key: str, flag: str) -> bool: """Return the value of a per-conversation boolean *flag* (default False).""" entry = self._data.get(canonical_key(key)) if not entry: return False flags = entry.get("flags") - return bool(flags and flags.get(flag)) + if not isinstance(flags, dict): + return False + if flag == EXPLICIT_EFFORT_DEFAULT_FLAG: + return _explicit_effort_default_value_on(flags) + return bool(flags.get(flag)) @_guarded def set_agent_override(self, key: str, agent: str | None) -> None: diff --git a/src/kiro_crew/session_parts/record.py b/src/kiro_crew/session_parts/record.py index b8d2eda7914..914c8ae63fa 100644 --- a/src/kiro_crew/session_parts/record.py +++ b/src/kiro_crew/session_parts/record.py @@ -58,6 +58,19 @@ def resumed(self) -> bool: return self is FirstTurnState.RESUMED +@dataclass +class _EffortIntentWrites: + """The effort intent writes in flight under one folded session key. + + ``count`` is how many picks are between their in-memory write and the end + of their save; ``settled`` is set when the last of them exits, which is + what a cold start waiting to read the key's effort basis wakes on. + """ + + count: int = 0 + settled: asyncio.Event = field(default_factory=asyncio.Event) + + @dataclass class _Session: provider: LLMProvider @@ -289,3 +302,4 @@ def _unlink_session_queue(session: "_Session") -> None: # must not leak into them. FirstTurnState.__module__ = "kiro_crew.session" _Session.__module__ = "kiro_crew.session" +_EffortIntentWrites.__module__ = "kiro_crew.session" diff --git a/src/kiro_crew/session_parts/runtimes.py b/src/kiro_crew/session_parts/runtimes.py index d90b62e8b8c..790b9ad4ab0 100644 --- a/src/kiro_crew/session_parts/runtimes.py +++ b/src/kiro_crew/session_parts/runtimes.py @@ -14,6 +14,7 @@ import logging from collections.abc import Callable +from contextlib import AbstractContextManager from typing import TYPE_CHECKING, Any, cast from kiro_crew.executors import subprocess_executor @@ -249,6 +250,16 @@ async def _drain_and_claim(self: SessionManager, agent: str | None) -> LLMProvid return await self._pool._drain_and_claim(agent) +def _take_claim_spawn_time(self: SessionManager, provider: LLMProvider) -> float | None: + """Consume a warm claim's spawn timestamp before allocation awaits again.""" + return self._pool._take_claim_spawn_time(provider) + + +def _may_predate_effort_overlay_rewrite(self: SessionManager, spawn_time: float) -> bool: + """Return whether a runtime spawned before the current effort rewrite settled.""" + return self._pool._may_predate_effort_overlay_rewrite(spawn_time) + + def _schedule_replenish(self: SessionManager) -> None: """Delegate owned refill-task scheduling.""" self._pool._schedule_replenish() @@ -348,6 +359,11 @@ def _mark_identity_epoch(self: SessionManager) -> None: self._pool.mark_identity_epoch() +def fence_effort_overlay_rewrite(self: SessionManager) -> AbstractContextManager[None]: + """Keep warm runtimes that may have read cli.json before an explicit Default's rewrite from claims.""" + return self._pool.fence_effort_overlay_rewrite() + + async def drain_warm_pool(self: SessionManager) -> list: """Remove and return all queued warm providers.""" return await self._pool.drain_warm_pool() diff --git a/src/kiro_crew/session_parts/turn_context.py b/src/kiro_crew/session_parts/turn_context.py index 7811eb1eb86..e7257bf8bd4 100644 --- a/src/kiro_crew/session_parts/turn_context.py +++ b/src/kiro_crew/session_parts/turn_context.py @@ -42,25 +42,35 @@ manager's own state on the instance, and a patch of the member on the class or on an instance reaches every caller. -Instance fields written: none. +Instance fields written: ``_effort_intent_writes``. Instance fields read: ``_cfg``, ``_compaction``, ``_session_map``. """ from __future__ import annotations -from collections.abc import Callable +import logging +from collections.abc import Callable, Iterator +from contextlib import AbstractContextManager, contextmanager from typing import TYPE_CHECKING from kiro_crew.config.loader import AUTOCOMPACT_PCT_MAX, AUTOCOMPACT_PCT_MIN -from kiro_crew.session_map import SUPPRESS_REPLAY_FLAG +from kiro_crew.messaging.link import canonical_key +from kiro_crew.session_map import ( + EXPLICIT_EFFORT_DEFAULT_FLAG, + SUPPRESS_REPLAY_FLAG, + EffortDefaultRowRefused, +) from kiro_crew.session_parts.policy import _resolve_compact_wait_secs +from kiro_crew.session_parts.record import _EffortIntentWrites if TYPE_CHECKING: from kiro_crew.session import LLMProvider, SessionManager from kiro_crew.session_parts.policy import _CompactCallback from kiro_crew.session_parts.record import _Session +logger = logging.getLogger("kiro_crew.session") + def compact_wait_budget_secs(self: SessionManager) -> float: """The compaction wait budget this manager's config is in force with. @@ -259,6 +269,124 @@ def suppress_replay_persistently(self: SessionManager, key: str) -> None: self._session_map.set_flag(self._fold_key(key), SUPPRESS_REPLAY_FLAG, True) +def set_explicit_effort_default(self: SessionManager, key: str, pending: bool) -> bool: + """Record whether *key* must replace an unowned effort overlay once.""" + folded = self._fold_key(key) + target = folded + if ( + pending + and folded != key + and self._session_map.explicit_effort_default_held(key) + and not self._session_map.explicit_effort_default_held(folded) + ): + # Armed again on the spelling whose row the hold kept counted. + target = key + try: + self._session_map.set_flag(target, EXPLICIT_EFFORT_DEFAULT_FLAG, pending) + except EffortDefaultRowRefused as exc: + logger.warning( + "Could not retain explicit effort Default for %s: %s", + exc.key, + exc.reason, + ) + return False + if not pending and folded != key: + self._session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, False) + return True + + +def effort_basis_locked(self: SessionManager, key: str) -> bool: + """Whether a cold start is in flight under *key*. + + A cold start reads the one-shot Default once and publishes a session + built on that read; it is marked from before that read until the + session is registered, so a pick landing meanwhile that would change + that basis is refused, not recorded. A pick that would leave it as it is + has nothing to record and passes. A caller waiting to claim an + already-live session, which can wait a whole turn, reads no basis and + does not lock it. + """ + return self._allocation_boundary().has_cold_start_reservation(key) + + +def _effort_intent_writes_in_flight(self: SessionManager) -> dict[str, _EffortIntentWrites]: + # Lazy, so a manager built without ``__init__`` (as tests do) still has + # the dict on its first write or wait. + writes = getattr(self, "_effort_intent_writes", None) + if writes is None: + writes = {} + setattr(self, "_effort_intent_writes", writes) + return writes + + +@contextmanager +def effort_intent_write(self: SessionManager, key: str) -> Iterator[None]: + """Count one write of *key*'s effort intent from its in-memory flag through its save. + + The effort handler holds this around :meth:`set_explicit_effort_default`, + the ``aflush`` that saves it and the put-back a failed or cancelled save + makes, entered with no await after its :meth:`effort_basis_locked` check. + The count uses the flag's canonical key, so a pick and cold start using + different spellings of one Slack thread meet before the start reads its + basis. It then reads either the saved value or the one the failed save + put back, never an in-memory value no save will keep. The key's entry is + removed once its last write has exited, so nothing here outlives a write. + """ + writes = self._effort_intent_writes_in_flight() + intent_key = canonical_key(key) + entry = writes.get(intent_key) + if entry is None: + entry = writes[intent_key] = _EffortIntentWrites() + entry.count += 1 + try: + yield + finally: + entry.count -= 1 + if entry.count == 0: + if writes.get(intent_key) is entry: + del writes[intent_key] + entry.settled.set() + + +async def wait_for_effort_intent_writes(self: SessionManager, key: str) -> None: + """Return once no write of *key*'s effort intent is in flight. + + Awaited by a cold start right before its single read of the key's effort + basis, with the key's allocation reservation already held: a later pick + that would change the basis is refused by :meth:`effort_basis_locked`, + and one that would not enters no write, so the count can only fall + while this waits. The count uses the flag's canonical key, so either + spelling of one Slack thread waits for the same write. Returns at once + when no write is in flight. Bounded by the pick's own save, one + worker-thread write of the session map. A cancellation while waiting + propagates; nothing was read or written. + """ + writes = getattr(self, "_effort_intent_writes", None) + if not writes: + return + intent_key = canonical_key(key) + while (entry := writes.get(intent_key)) is not None: + await entry.settled.wait() + + +def hold_explicit_effort_default(self: SessionManager, key: str) -> AbstractContextManager[None]: + """Keep *key*'s explicit-Default row counted while a pick may clear it. + + A pick holds both spellings read by + :meth:`explicit_effort_default_pending` until the request ends, so it + can put the flag back and no other pick can take the row meanwhile. + """ + return self._session_map.hold_explicit_effort_default(self._fold_key(key), key) + + +def explicit_effort_default_pending(self: SessionManager, key: str) -> bool: + """Return whether either spelling of *key* carries the one-shot Default.""" + folded = self._fold_key(key) + return self._session_map.get_flag( + folded, EXPLICIT_EFFORT_DEFAULT_FLAG + ) or self._session_map.get_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG) + + def _compaction_gate_decision( self: SessionManager, key: str, provider: LLMProvider, pct: float ) -> str | None: diff --git a/src/kiro_crew/session_pool.py b/src/kiro_crew/session_pool.py index 6478b28bf6c..14ce7d16d62 100644 --- a/src/kiro_crew/session_pool.py +++ b/src/kiro_crew/session_pool.py @@ -16,8 +16,9 @@ import logging import threading import time -from collections.abc import Callable +from collections.abc import Callable, Iterator from concurrent.futures import Executor +from contextlib import contextmanager from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol @@ -128,6 +129,18 @@ class WarmPoolState: # disqualified from being claimed no matter when the retirement sweep gets # around to shutting it down. ``0.0`` means no sweep has run. identity_epoch: float = 0.0 + # Monotonic instant the most recent explicit-Default rewrite of a workspace + # ``cli.json`` settled. A queued kiro-cli read that file at its own spawn, so + # one spawned at or before it may run the effort entry just removed. ``0.0`` + # means no such rewrite has happened. + effort_overlay_epoch: float = 0.0 + # Explicit-Default rewrites still in progress. The file can change at any + # point during one, so while any is, no queued kiro-cli is known to have + # read the file that results. + effort_overlay_rewrites: int = 0 + # A successful claim is immediately consumed by allocation before it awaits + # again. Store only its timestamp, never the provider itself. + claimed_spawn_times: dict[int, float] = field(default_factory=dict) @dataclass(frozen=True, slots=True) @@ -313,6 +326,30 @@ def _claimed_under_previous_identity(self, provider: LLMProvider, spawn_time: fl return False return self._deps.get_identity_predicate()(provider) + @contextmanager + def fence_effort_overlay_rewrite(self) -> Iterator[None]: + """Refuse every claim while an explicit Default rewrites cli.json, then each one queued before. + + Entered before the write starts and left once it has settled, so no claim + falls between the file changing and the fence knowing it did. After it, + an age check like the identity fence: claims take no lock this could + hold, and a fill whose start spans the rewrite is stamped with its start. + A write that changed nothing still moves the epoch, which costs at most + the queued runtimes' respawns. + """ + self.state.effort_overlay_rewrites += 1 + try: + yield + finally: + self.state.effort_overlay_rewrites -= 1 + self.state.effort_overlay_epoch = time.monotonic() + + def _may_predate_effort_overlay_rewrite(self, spawn_time: float) -> bool: + if self.state.effort_overlay_rewrites: + return True + epoch = self.state.effort_overlay_epoch + return bool(epoch) and spawn_time <= epoch + async def start_pool(self, *, blocking: bool = True) -> None: """Start the background session and configured warm-pool workers. @@ -656,6 +693,16 @@ async def _drain_and_claim(self, agent: str | None) -> LLMProvider | None: claimed = self._owner._claim_from_pool(agent) continue + if self._may_predate_effort_overlay_rewrite(spawn_time): + self._deps.logger.info( + "Warm pool: claimed provider may have read cli.json before an explicit " + "effort Default rewrote it, discarding" + ) + discarded = True + await self._owner._discard_pool_provider(provider, "Warm pool effort discard") + claimed = self._owner._claim_from_pool(agent) + continue + age = time.monotonic() - spawn_time if self._pool_ttl_secs and age > self._pool_ttl_secs: try: @@ -684,12 +731,17 @@ async def _drain_and_claim(self, agent: str | None) -> LLMProvider | None: await self._owner._discard_pool_provider(provider, "Warm pool discard") claimed = self._owner._claim_from_pool(agent) continue + self.state.claimed_spawn_times[id(provider)] = spawn_time return provider if discarded: self._owner._schedule_replenish() return None + def _take_claim_spawn_time(self, provider: LLMProvider) -> float | None: + """Consume a successful claim's spawn time before allocation can await again.""" + return self.state.claimed_spawn_times.pop(id(provider), None) + def _schedule_replenish(self) -> None: """Schedule a refill task owned by the facade.""" if not self._pool_size: diff --git a/src/kiro_crew/workspace_cli_settings.py b/src/kiro_crew/workspace_cli_settings.py index 7e5a2ab4f03..74c62ed58a9 100644 --- a/src/kiro_crew/workspace_cli_settings.py +++ b/src/kiro_crew/workspace_cli_settings.py @@ -12,24 +12,44 @@ from __future__ import annotations +import errno +import json import logging import os import stat -from collections.abc import Iterator +import time +from collections.abc import Callable, Iterator from contextlib import ExitStack, contextmanager -from pathlib import Path +from dataclasses import dataclass +from pathlib import Path, PureWindowsPath +from typing import Any +from kiro_crew import atomic_write as atomic_write_module from kiro_crew import pinned_fs, platform_compat +from kiro_crew.security import canonical_path_refusal logger = logging.getLogger(__name__) CLI_SETTINGS_LOCK_NAME = ".kirocrew-cli-settings.lock" _EXCLUDE_COMMENT = "# Kiro Crew session settings (local only)" _READ_LIMIT = 1 << 20 +#: What every reader of a workspace ``cli.json`` says about a shape it refuses, +#: after the file it names. One definition, so the effort overlay and skill +#: projection report the same refusal in the same words. +CLI_JSON_REFUSED = ( + "is a link, a hardlink, not a regular file inside the settings directory, or cannot be " + "read; left unchanged" +) _EXCLUDE_LINES = ( "/.kiro/settings/cli.json", f"/.kiro/settings/{CLI_SETTINGS_LOCK_NAME}", ) +#: The effort levels Kiro Crew wrote into a workspace ``cli.json``, by model. +EFFORT_OWNED_KEY = "kirocrew.effortOwned" +#: The whole-second mtime the publishing writer gave the file. The record under +#: :data:`EFFORT_OWNED_KEY` counts only while this equals the mtime of the file +#: the reader used, so any rewrite that does not carry the stamp voids it. +EFFORT_OWNED_STAMP_KEY = "kirocrew.effortOwnedStamp" #: The STARTUP ceiling. A native launch cannot wait long for this file, and a #: launch that loses the lock has a safe fallback (authored agents). CLI_SETTINGS_LOCK_TIMEOUT_SECS = 2.0 @@ -40,6 +60,259 @@ #: for a failure that only a stuck holder produces. Callers using it MUST be off #: the event loop: waiting this long on it would freeze every session. CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS = 30.0 +#: ``stat.FILE_ATTRIBUTE_REPARSE_POINT``, which the type stubs declare for Windows only. +_FILE_ATTRIBUTE_REPARSE_POINT: int = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + + +@dataclass(frozen=True) +class LockedCliSettings: + """The workspace ``cli.json`` and its settings directory while the lock is held. + + ``settings_fd`` is the settings directory the lock opened without following links, or + ``None`` on the by-name floor. The lock owns and closes it. + """ + + cli_json: Path + settings_dir: Path + settings_fd: int | None + + def holds_named_settings_dir(self) -> bool: + """Whether the settings directory reached by name is still the one the lock opened.""" + return self.settings_fd is not None and _is_pinned_directory( + self.settings_fd, self.settings_dir + ) + + +_SETTINGS_DIR_OUTSIDE_WORK_DIR = ( + "workspace CLI settings directory resolves outside the work directory or loops" +) +_SETTINGS_DIR_SENSITIVE = ( + "workspace CLI settings directory is on a sensitive path or cannot be verified" +) + + +def _refuse_sensitive_settings_path(path: str | None) -> None: + """Raise before a workspace CLI settings path can be opened or created beneath.""" + if path is None or canonical_path_refusal(path) is not None: + raise OSError(_SETTINGS_DIR_SENSITIVE) + + +def _refuse_sensitive_settings_descriptor( + settings_fd: int, *, by_name_fallback: Path | None = None +) -> None: + """Raise when the held settings directory is sensitive or cannot be identified.""" + opened_path = pinned_fs.fd_real_path(settings_fd) + if opened_path is None and by_name_fallback is not None: + opened_path = os.fspath(by_name_fallback) + _refuse_sensitive_settings_path(opened_path) + + +def _resolved_work_dir(work_dir: Path) -> Path: + """Resolve the trusted *work_dir* once; a loop in it is refused by the first probe below it.""" + return Path(os.path.realpath(work_dir)) + + +def _normalize_windows_link_target(target: str | PureWindowsPath) -> PureWindowsPath: + r"""Return a Windows link target without NT device-path spelling. + + ``os.readlink`` may return either extended DOS spelling or the NT object + manager spelling. The normalization is lexical: it never probes the path. + An extended UNC target becomes an ordinary UNC target so the caller can + compare its drive and components with the trusted work directory. + """ + text = str(target) + for prefix in ("\\\\?\\UNC\\", "\\??\\UNC\\"): + if text.startswith(prefix): + return PureWindowsPath("\\\\" + text[len(prefix) :]) + for prefix in ("\\\\?\\", "\\??\\"): + if text.startswith(prefix): + return PureWindowsPath(text[len(prefix) :]) + return PureWindowsPath(text) + + +def _target_parts_within_work_dir(target: str, work_dir_real: Path) -> tuple[bool, tuple[str, ...]]: + """Return whether *target* is absolute and its lexically admitted parts. + + Relative targets are returned for in-place processing. Absolute targets + are admitted only when they contain no parent component and name a path + inside the resolved work directory. No filesystem operation occurs here. + """ + if os.name == "nt": + windows_target = _normalize_windows_link_target(target) + if windows_target.drive.startswith("\\\\"): + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + if windows_target.root and not windows_target.drive: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + if windows_target.drive and not windows_target.is_absolute(): + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + if not windows_target.is_absolute(): + return False, windows_target.parts + if ".." in windows_target.parts: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + work_path = PureWindowsPath(str(work_dir_real)) + target_parts = tuple(os.path.normcase(part) for part in windows_target.parts) + work_parts = tuple(os.path.normcase(part) for part in work_path.parts) + if target_parts[: len(work_parts)] != work_parts: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + return True, windows_target.parts[len(work_path.parts) :] + + posix_target = Path(target) + if not posix_target.is_absolute(): + return False, posix_target.parts + if ".." in posix_target.parts: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + try: + return True, posix_target.relative_to(work_dir_real).parts + except ValueError as exc: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) from exc + + +def _settings_dir_within_work_dir(work_dir: Path) -> Path: + """Resolve the workspace settings folder without touching an outside target. + + The trusted work directory is resolved once. From there, each component is + inspected with ``lstat`` and a link is expanded from its ``readlink`` text. + Absolute, remote, or escaping targets are rejected lexically before their + targets are probed. Missing components stay as named so the locked walk can + create them. More than 40 link expansions is treated as a loop. + + A process that can swap a component between two probes in one pass can make + one later ``lstat`` follow that replacement. The pinned walk still refuses + the changed chain before any write is redirected, and such a process already + has the access that the one probe could expose. + """ + work_dir_real = _resolved_work_dir(work_dir) + prefix = work_dir_real + pending = [".kiro", "settings"] + link_expansions = 0 + + while pending: + name = pending.pop(0) + if name in ("", "."): + continue + if name == "..": + if prefix == work_dir_real: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + prefix = prefix.parent + continue + if Path(name).name != name: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + + candidate = prefix / name + try: + info = os.lstat(candidate) + except FileNotFoundError: + for remaining in pending: + if remaining in ("", ".", "..") or Path(remaining).name != remaining: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + return candidate.joinpath(*pending) + except OSError as exc: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) from exc + + is_link = ( + stat.S_ISLNK(info.st_mode) + or platform_compat.lstat_is_name_surrogate(info) + or platform_compat.is_link_or_junction(candidate) + ) + if is_link: + link_expansions += 1 + if link_expansions > 40: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) + try: + target = os.readlink(candidate) + except OSError as exc: + raise OSError(_SETTINGS_DIR_OUTSIDE_WORK_DIR) from exc + absolute, target_parts = _target_parts_within_work_dir(target, work_dir_real) + if absolute: + prefix = work_dir_real + pending[:0] = list(target_parts) + continue + if not stat.S_ISDIR(info.st_mode): + raise OSError("workspace CLI settings path component is not a directory") + prefix = candidate + + return prefix + + +def admitted_workspace_cli_settings_dir(work_dir: Path) -> Path | None: + """Return the admitted settings folder, or ``None`` without probing its target.""" + try: + return _settings_dir_within_work_dir(work_dir) + except OSError: + return None + + +def workspace_cli_settings_fence_key(work_dir: Path) -> str: + """Return the admitted folder, or a named fallback beneath the resolved work dir. + + Admission refusal must not resolve the refused settings link. Failure to + resolve the trusted work directory still raises, so starts for one work + directory cannot silently use different fence keys. + """ + admitted = admitted_workspace_cli_settings_dir(work_dir) + if admitted is not None: + return os.fspath(admitted) + return os.path.join(os.path.realpath(work_dir), ".kiro", "settings") + + +def _settings_chain(work_dir_real: Path, settings_dir: Path) -> tuple[str, ...]: + """The directory names from the resolved work dir down to the resolved settings folder.""" + return settings_dir.relative_to(work_dir_real).parts + + +def _pinned_settings_dir_fd(work_dir_real: Path, settings_dir: Path) -> int: + """Return a settings-directory descriptor reached without re-opening an ancestor. + + The walk follows the chain the resolution produced, one ``openat`` per component with + ``O_NOFOLLOW``, so a component replaced by a link after the resolution is refused rather + than followed. + + Not ``pinned_fs.open_pinned_descendant_dir``: that opens its root by name, so an ancestor + of the work dir swapped for a link is followed, while ``pin_parent`` here pins every + component from the filesystem root. It also creates each folder 0o700, and these are the + user's own ``.kiro`` folders, which keep the mode the umask gives them. + """ + parent_fd = pinned_fs.pin_parent( + str(work_dir_real), + what="workspace CLI settings directory", + refusal=OSError, + ) + try: + for name in _settings_chain(work_dir_real, settings_dir): + try: + os.mkdir(name, 0o777, dir_fd=parent_fd) + except FileExistsError: + pass + try: + child_fd = os.open(name, pinned_fs.dir_flags(), dir_fd=parent_fd) + except OSError as exc: + if exc.errno in (errno.ELOOP, errno.ENOTDIR, errno.EMLINK): + raise OSError( + "workspace CLI settings directory is linked or not a directory" + ) from exc + raise + os.close(parent_fd) + parent_fd = child_fd + return parent_fd + except BaseException: + os.close(parent_fd) + raise + + +def _is_pinned_directory(settings_fd: int, settings_dir: Path) -> bool: + """Whether ``settings_dir`` reached by name is still the directory ``settings_fd`` holds. + + A reparse point set on the held directory itself keeps its file id, so it is refused by its + attribute: on Windows an empty folder can take one without being renamed or deleted. + """ + named = pinned_fs.lstat_by_name(settings_dir) + pinned = os.fstat(settings_fd) + return ( + named is not None + and stat.S_ISDIR(named.st_mode) + and not getattr(named, "st_file_attributes", 0) & _FILE_ATTRIBUTE_REPARSE_POINT + and (named.st_dev, named.st_ino) == (pinned.st_dev, pinned.st_ino) + ) def _read_small(path: Path) -> str: @@ -136,18 +409,94 @@ def _exclude_from_git(work_dir: Path) -> None: @contextmanager -def workspace_cli_settings_lock( +def locked_workspace_cli_settings( work_dir: Path, *, timeout: float = CLI_SETTINGS_LOCK_TIMEOUT_SECS -) -> Iterator[Path]: - """Yield a workspace ``cli.json`` path while its verified lock is held.""" +) -> Iterator[LockedCliSettings]: + """Yield the workspace ``cli.json`` and its settings directory while the verified lock is held. + + The settings directory is the one ``/.kiro/settings`` resolves to, when that stays + inside the resolved work dir; a chain that leaves it or loops is refused, and so is a folder + on a sensitive path or one whose real path cannot be verified, before anything beneath it is + created or opened. The directories + from the work dir down to it are then reached by name as resolved, never through a link: a + component swapped for a link after the resolution is refused. Windows creates and pins each + directory before creating the next beneath it because descriptor-relative directory walks + are unavailable; the held folder is checked before and after the lock file opens, so a + reparse point set on it is refused. The work directory is resolved once, and every later + resolution is compared with it and refused on difference. + """ stack = ExitStack() try: - settings_dir = work_dir / ".kiro" / "settings" - settings_dir.mkdir(parents=True, exist_ok=True) + settings_dir = _settings_dir_within_work_dir(work_dir) + _refuse_sensitive_settings_path(os.fspath(settings_dir)) + work_dir_real = _resolved_work_dir(work_dir) + if not settings_dir.is_relative_to(work_dir_real): + raise OSError("workspace CLI settings directory changed while it was opened") lock_path = settings_dir / CLI_SETTINGS_LOCK_NAME - if platform_compat.is_link_or_junction(lock_path): - raise OSError("workspace CLI settings lock is a symlink or junction") - lock_fd = stack.enter_context(platform_compat.open_lock_file(lock_path)) + settings_fd: int | None = None + settings_pin_fd: int | None = None + if pinned_fs.supports_pinned_walk(): + work_dir.mkdir(parents=True, exist_ok=True) + if _settings_dir_within_work_dir(work_dir) != settings_dir: + raise OSError("workspace CLI settings directory changed while it was opened") + settings_fd = _pinned_settings_dir_fd(work_dir_real, settings_dir) + stack.callback(os.close, settings_fd) + _refuse_sensitive_settings_descriptor(settings_fd) + try: + lock_fd = platform_compat.open_create_or_existing( + CLI_SETTINGS_LOCK_NAME, + os.O_RDWR | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0), + 0o644, + dir_fd=settings_fd, + ) + except OSError as exc: + if exc.errno in (errno.ELOOP, errno.EMLINK): + raise OSError("workspace CLI settings lock is a symlink or junction") from exc + if exc.errno == errno.ENOENT: + raise OSError( + "workspace CLI settings directory or lock file was removed while the lock" + " was being opened" + ) from exc + raise + stack.callback(os.close, lock_fd) + else: + work_dir.mkdir(parents=True, exist_ok=True) + held: list[tuple[int, Path]] = [] + level = work_dir_real + for name in _settings_chain(work_dir_real, settings_dir): + if held and _settings_dir_within_work_dir(work_dir) != settings_dir: + raise OSError("workspace CLI settings directory changed while it was opened") + level = level / name + try: + level.mkdir(exist_ok=True) + except FileExistsError as exc: + raise OSError( + "workspace CLI settings directory is linked or not a directory" + ) from exc + try: + level_fd = platform_compat.pin_directory(level) + stack.callback(os.close, level_fd) + except OSError as exc: + if exc.errno in (errno.ELOOP, errno.ENOTDIR, errno.EMLINK): + raise OSError( + "workspace CLI settings directory is linked or not a directory" + ) from exc + raise + held.append((level_fd, level)) + # A held directory can take a reparse point in place while it is empty, so + # every held level is checked again once the next exists in it, after which + # it cannot take one. + if not all(_is_pinned_directory(fd, path) for fd, path in held): + raise OSError("workspace CLI settings directory changed while it was opened") + if not held: + held.append((platform_compat.pin_directory(settings_dir), settings_dir)) + stack.callback(os.close, held[0][0]) + settings_pin_fd = held[-1][0] + _refuse_sensitive_settings_descriptor(settings_pin_fd, by_name_fallback=settings_dir) + if platform_compat.is_link_or_junction(lock_path): + raise OSError("workspace CLI settings lock is a symlink or junction") + lock_fd = stack.enter_context(platform_compat.open_lock_file(lock_path)) + pinned_settings_fd = settings_fd if settings_fd is not None else settings_pin_fd opened = os.fstat(lock_fd) named = pinned_fs.lstat_by_name(lock_path) if ( @@ -158,6 +507,10 @@ def workspace_cli_settings_lock( or (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino) ): raise OSError("workspace CLI settings lock changed while it was opened") + if pinned_settings_fd is not None and not _is_pinned_directory( + pinned_settings_fd, settings_dir + ): + raise OSError("workspace CLI settings directory changed while it was opened") stack.enter_context( platform_compat.file_lock( lock_fd, @@ -172,11 +525,199 @@ def workspace_cli_settings_lock( or (current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino) ): raise OSError("workspace CLI settings lock changed while it was acquired") + if pinned_settings_fd is not None and not _is_pinned_directory( + pinned_settings_fd, settings_dir + ): + raise OSError("workspace CLI settings directory changed while it was acquired") try: _exclude_from_git(work_dir) except OSError as exc: # Best effort: a failed exclude must never block the overlay itself. logger.debug("could not add CLI settings overlay to git exclude: %s", exc) - yield settings_dir / "cli.json" + yield LockedCliSettings( + cli_json=settings_dir / "cli.json", + settings_dir=settings_dir, + settings_fd=settings_fd, + ) finally: stack.close() + + +@contextmanager +def workspace_cli_settings_lock( + work_dir: Path, *, timeout: float = CLI_SETTINGS_LOCK_TIMEOUT_SECS +) -> Iterator[Path]: + """Yield a workspace ``cli.json`` path while its verified lock is held. + + Windows keeps the by-name floor because descriptor-relative directory walks are unavailable, + and holds every directory from the work dir down to the resolved settings folder open until + the lock is released. + """ + with locked_workspace_cli_settings(work_dir, timeout=timeout) as settings: + yield settings.cli_json + + +def pinned_cli_settings_fd(settings: LockedCliSettings) -> int | None: + """Return the descriptor both cli.json reads and replacements can use.""" + if settings.settings_fd is None or not atomic_write_module.pinned_parent_replace_supported(): + return None + return settings.settings_fd + + +def read_pinned_cli_json( + settings_fd: int, cli_json: Path, *, max_bytes: int = _READ_LIMIT +) -> tuple[bytes, os.stat_result, os.stat_result] | None: + """Read cli.json relative to a pinned settings directory without following links. + + Returns the bytes, at most ``max_bytes + 1`` so a caller can tell a file at + the cap from one past it, with the opened file's metadata from before and + after the read, so a caller can tell whether the version it read changed + under it. An absent file reads as ``None``. + + Raises ``OSError`` for a link, a hardlink, a non-regular file, a file whose + opened descriptor names a sensitive path or cannot be resolved, and one that + cannot be read. + """ + if max_bytes < 0: + raise ValueError("max_bytes must be non-negative") + refusal = f"workspace cli.json overlay {cli_json} {CLI_JSON_REFUSED}" + try: + fd = os.open( + "cli.json", + os.O_RDONLY + | getattr(os, "O_NOFOLLOW", 0) + | getattr(os, "O_NONBLOCK", 0) + | getattr(os, "O_CLOEXEC", 0), + dir_fd=settings_fd, + ) + except FileNotFoundError: + return None + except OSError as exc: + raise OSError(refusal) from exc + try: + opened = os.fstat(fd) + if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1: + raise OSError(refusal) + opened_path = pinned_fs.fd_real_path(fd) + if opened_path is None or canonical_path_refusal(opened_path) is not None: + raise OSError(refusal) + except BaseException: + os.close(fd) + raise + with os.fdopen(fd, "rb") as handle: + raw = handle.read(max_bytes + 1) + return raw, opened, os.fstat(handle.fileno()) + + +def publish_locked_cli_settings( + settings: LockedCliSettings, + content: str | bytes, + *, + write: Callable[..., None], + mtime_ns: int | None = None, + check_named_before: bool = True, +) -> None: + """Publish cli.json through the directory held by the settings lock where supported. + + Where pinned replace is supported the write goes through the held + descriptor, and ``OSError`` is raised when the folder reached by name is not + the one the lock holds: before the write, unless ``check_named_before=False`` + for a caller that has just made that check, and after it. Elsewhere it + writes by name. + """ + settings_fd = pinned_cli_settings_fd(settings) + if settings_fd is not None and check_named_before and not settings.holds_named_settings_dir(): + raise OSError("workspace CLI settings directory changed before the overlay was written") + if settings_fd is None: + write(settings.cli_json, content, mtime_ns=mtime_ns) + return + write( + settings.cli_json, + content, + parent_dir_fd=settings_fd, + mtime_ns=mtime_ns, + ) + if not settings.holds_named_settings_dir(): + raise OSError("workspace CLI settings directory changed while the overlay was written") + + +def effort_ownership_stamp_matches(document: dict[str, Any], file_mtime: int | None) -> bool: + """Whether *document*'s ownership stamp names the file version *file_mtime* was read from.""" + stamp = document.get(EFFORT_OWNED_STAMP_KEY) + return type(stamp) is int and file_mtime is not None and stamp == file_mtime + + +def stamp_effort_ownership(document: dict[str, Any]) -> int | None: + """Stamp a *document* carrying an ownership record; return the ``mtime_ns`` that publishes it. + + A document without a record under :data:`EFFORT_OWNED_KEY` has nothing for a + stamp to validate, so a stamp it carries is dropped and ``None`` leaves the + published file's mtime natural. + + A natural rewrite can be dated one clock tick behind ``time.time()``, and FAT + rounds mtimes down to an even second. An even second at least three seconds + back is stored exactly and remains below any later natural rewrite. + """ + if EFFORT_OWNED_KEY not in document: + document.pop(EFFORT_OWNED_STAMP_KEY, None) + return None + stamp = (int(time.time()) - 3) // 2 * 2 + document[EFFORT_OWNED_STAMP_KEY] = stamp + return stamp * 1_000_000_000 + + +def _without_effort_ownership_stamp(document: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in document.items() if key != EFFORT_OWNED_STAMP_KEY} + + +def owned_document_unchanged( + document: dict[str, Any], read_document: dict[str, Any], file_mtime: int | None +) -> bool: + """Whether a write of *document* would republish the owned file it was read from. + + *read_document* is the file version *file_mtime* was read from, as read. When + its record is valid, its stamp names that version, and *document* equals it + apart from the stamp, the write has nothing to change: skipping it keeps the + file's bytes and its mtime, so the record stays valid without a restamp. A + file with no record, or with a void one, is never kept this way; that write + goes through and drops or claims the record under the writer's own rule. + """ + return ( + EFFORT_OWNED_KEY in read_document + and effort_ownership_stamp_matches(read_document, file_mtime) + and _without_effort_ownership_stamp(document) + == _without_effort_ownership_stamp(read_document) + ) + + +def carry_effort_ownership(document: dict[str, Any], file_mtime: int | None) -> int | None: + """Prepare a rewrite of *document* that keeps a valid ownership record valid and a void one void. + + For a writer that changes other settings and only carries the effort keys through. + A record whose stamp matches *file_mtime* is restamped, and the returned ``mtime_ns`` + must be passed to ``atomic_write`` so the published file still validates it. Any + other record is dropped before the write, because no later Kiro Crew write may + re-validate an entry Kiro Crew cannot prove it wrote; ``None`` then leaves the + file's mtime natural. + """ + if effort_ownership_stamp_matches(document, file_mtime): + return stamp_effort_ownership(document) + document.pop(EFFORT_OWNED_KEY, None) + document.pop(EFFORT_OWNED_STAMP_KEY, None) + return None + + +def settings_json_text(document: dict[str, Any]) -> str: + """Serialize a workspace ``cli.json`` document for a Kiro Crew rewrite. + + Non-ASCII text stays UTF-8 rather than ``\\uXXXX`` escapes, which can double its + size and push the file past the effort overlay's 1 MiB read ceiling. A document + whose text cannot be UTF-8 encoded, such as an unpaired surrogate escape that + ``json.loads`` accepted, keeps the escaped form, which still round-trips. + """ + text = json.dumps(document, indent=2, ensure_ascii=False) + try: + text.encode("utf-8") + except UnicodeEncodeError: + return json.dumps(document, indent=2) + return text diff --git a/test/chat_slot_reasoning_effort_helpers.py b/test/chat_slot_reasoning_effort_helpers.py new file mode 100644 index 00000000000..b6ec3ea58cf --- /dev/null +++ b/test/chat_slot_reasoning_effort_helpers.py @@ -0,0 +1,87 @@ +"""The app and state doubles shared by the ``test_chat_slot_reasoning_effort*.py`` modules.""" + +from __future__ import annotations + +import contextlib +from unittest.mock import AsyncMock, MagicMock + +from aiohttp import web + +from kiro_crew.dashboard.chat import api_chat_slot_reasoning_effort +from kiro_crew.dashboard.chat_handlers import api_chat_slot_selection_capabilities +from kiro_crew.dashboard.state import DashboardState, _ChatSlot + + +def _make_app(state: DashboardState) -> web.Application: + app = web.Application() + app["state"] = state + app.router.add_post("/api/chat/slots/{slot}/reasoning-effort", api_chat_slot_reasoning_effort) + app.router.add_get( + "/api/chat/slots/{slot}/selection-capabilities", api_chat_slot_selection_capabilities + ) + return app + + +def _mock_state(slot: _ChatSlot | None = None, provider: object = None) -> DashboardState: + state = MagicMock(spec=DashboardState) + state._slots = {} + if slot: + state._slots[slot.key] = slot + state.push_slots_update = MagicMock() + state.sessions = MagicMock() + state.sessions.reset = AsyncMock() + state.sessions.aflush = AsyncMock() + # The explicit-Default intent the handler records per session key, kept in + # a dict so a test reads the value a request finally leaves. + intents: dict[str, bool] = {} + state.sessions.default_intents = intents + # Every intent write and row hold in order, so a test can read when a hold + # began and ended relative to the writes it covers. + events: list[tuple[object, ...]] = [] + state.sessions.effort_events = events + + def set_explicit_effort_default(key: str, pending: bool) -> bool: + events.append(("set", key, pending)) + intents[key] = pending + return True + + @contextlib.contextmanager + def hold_explicit_effort_default(key: str): + events.append(("hold", key)) + try: + yield + finally: + events.append(("release", key)) + + state.sessions.set_explicit_effort_default = MagicMock(side_effect=set_explicit_effort_default) + state.sessions.hold_explicit_effort_default = MagicMock( + side_effect=hold_explicit_effort_default + ) + state.sessions.explicit_effort_default_pending = MagicMock( + side_effect=lambda key: intents.get(key, False) + ) + # No cold start in flight under any key, so the handler's intent writes are + # not refused; a test that holds a start sets this per key. + state.sessions.effort_basis_locked = MagicMock(return_value=False) + # The handler's intent writes are counted per key from the in-memory flag + # through the save, so a cold start reading the basis can wait for them. + # ``in_flight_effort_intent_writes`` is the live count; a test reads it + # from inside a save to see that the write is still counted there. + in_flight_writes: dict[str, int] = {} + state.sessions.in_flight_effort_intent_writes = in_flight_writes + + @contextlib.contextmanager + def effort_intent_write(key: str): + in_flight_writes[key] = in_flight_writes.get(key, 0) + 1 + try: + yield + finally: + in_flight_writes[key] -= 1 + if in_flight_writes[key] == 0: + del in_flight_writes[key] + + state.sessions.effort_intent_write = MagicMock(side_effect=effort_intent_write) + # No live AcpProvider by default → handler falls back to session reset + # (matches prior behaviour for the "no session yet" path). + state.sessions.get_provider = MagicMock(return_value=provider) + return state diff --git a/test/chat_slot_switch_atomicity_helpers.py b/test/chat_slot_switch_atomicity_helpers.py index 1cb719e1721..f9d8eedb154 100644 --- a/test/chat_slot_switch_atomicity_helpers.py +++ b/test/chat_slot_switch_atomicity_helpers.py @@ -7,6 +7,7 @@ from __future__ import annotations +import contextlib from unittest.mock import AsyncMock, MagicMock from aiohttp import web @@ -56,6 +57,13 @@ def _mock_state(slot: _ChatSlot, provider: object = None) -> DashboardState: state.broadcast_context_usage = MagicMock() state.sessions = MagicMock() state.sessions.reset = AsyncMock() + # The effort switch saves its explicit-Default intent through the map. + state.sessions.aflush = AsyncMock() + state.sessions.explicit_effort_default_pending = MagicMock(return_value=False) + state.sessions.effort_basis_locked = MagicMock(return_value=False) + state.sessions.effort_intent_write = MagicMock( + side_effect=lambda _key: contextlib.nullcontext() + ) # No live AcpProvider by default → the model handler takes the reset path. state.sessions.get_provider = MagicMock(return_value=provider) # Not a channel-resumed session by default, so a mid-turn pick on a pure diff --git a/test/conftest.py b/test/conftest.py index b0d575d242d..f1632ac557f 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -262,6 +262,7 @@ def bundled_python_with_user_site(monkeypatch: pytest.MonkeyPatch) -> None: isolate_mcp_host_paths, make_escaping_link, named_cron_caller, + no_zero_tool_check, opened, plant_day_link, short_sock_dir, diff --git a/test/conftest_opt_in.py b/test/conftest_opt_in.py index 530ba0125d5..ba4c37dcd9f 100644 --- a/test/conftest_opt_in.py +++ b/test/conftest_opt_in.py @@ -487,3 +487,22 @@ def _ample(cfg: object | None = None) -> "resource_status.ResourceStatus": ) monkeypatch.setattr(resource_status, "probe", _ample) + + +@pytest.fixture +def no_zero_tool_check(monkeypatch: pytest.MonkeyPatch) -> None: + """Turn off the post-start MCP zero-tool check on every ``AcpProvider``. + + For a test that stubs ``_start_kiro_runtime``: the provider then keeps the + ``AcpClient`` it was built with, and the zero-tool check reads ``/mcp`` + through that client, which starts it for real. That spawn prepares a native + skill projection the test never releases, so the garbage collector ends it + later on the projection reaper thread, inside whichever test is running by + then, and its prune lands in that test's patches and refusal counters. + """ + from kiro_crew.providers.acp import AcpProvider + + async def _skip_zero_tool_check(self: AcpProvider) -> None: + return None + + monkeypatch.setattr(AcpProvider, "_note_zero_tool_servers", _skip_zero_tool_check) diff --git a/test/effort_helpers.py b/test/effort_helpers.py new file mode 100644 index 00000000000..de42c897a27 --- /dev/null +++ b/test/effort_helpers.py @@ -0,0 +1,77 @@ +"""Helpers shared by the ``test_effort*.py`` modules. + +The unparseable-overlay parametrization, the cli.json effort reader that other +test modules import from ``test_effort`` as well, and an observer for a Default +start waiting on its work directory's overlay fence. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +_UNPARSEABLE_OVERLAY_CONTENTS = pytest.mark.parametrize( + "contents", + [ + b'{"chat.modelDefaults": {"m": {"output_config": {"effort": "max"}}}, "n": ' + + b"1" * 5000 + + b"}", + b"[" * 200_000 + b"]" * 200_000, + ], + ids=["oversized-int-literal", "deeply-nested"], +) + + +def _read_cli_overlay(work_dir: Path) -> dict[str, str]: + """Return effort entries from a workspace cli.json for test assertions.""" + cli_json = work_dir / ".kiro" / "settings" / "cli.json" + if not cli_json.exists(): + return {} + try: + data = json.loads(cli_json.read_text(encoding="utf-8")) + except OSError: + return {} + except Exception: + return {} + if not isinstance(data, dict): + return {} + model_defaults = data.get("chat.modelDefaults") + if not isinstance(model_defaults, dict): + return {} + result: dict[str, str] = {} + for model, config in model_defaults.items(): + if not isinstance(config, dict): + continue + for key in ("output_config", "reasoning"): + effort_config = config.get(key) + if not isinstance(effort_config, dict): + continue + effort = effort_config.get("effort") + if isinstance(effort, str) and effort: + result[model] = effort + break + return result + + +def _observe_directory_wait(monkeypatch) -> asyncio.Event: + """Set an event when a Default start begins waiting for its directory's writes. + + Install it after any start that must not trip it has passed that wait: every + Default start calls it, including one that finds the directory free. + """ + from kiro_crew.providers import acp as acp_provider + + waiting = asyncio.Event() + original = acp_provider._WorkspaceEffortOverlayFence.wait_until_writes_clear + + async def observed(fence, timeout): + waiting.set() + return await original(fence, timeout) + + monkeypatch.setattr( + acp_provider._WorkspaceEffortOverlayFence, "wait_until_writes_clear", observed + ) + return waiting diff --git a/test/link_screen_sites.py b/test/link_screen_sites.py index 8ea86fe17fd..ca40550b613 100644 --- a/test/link_screen_sites.py +++ b/test/link_screen_sites.py @@ -30,6 +30,7 @@ ("acp/skill_projection_parts/alias_reclaim.py", "_migrate_legacy_side_files"), ("acp/skill_projection_parts/alias_reclaim.py", "_sweep_orphan_alias_locks"), ("acp/skill_projection_parts/alias_reclaim.py", "_sweep_orphan_sidecars_in"), + ("acp/skill_projection_parts/inheritance_overlay.py", "_settings_with_mtime.read_once"), ("acp/skill_projection_parts/side_files.py", "_ensure_real_directory"), ("acp/skill_projection_parts/side_files.py", "_hold_legacy_publication_lock"), ("acp/skill_projection_parts/side_files.py", "_probe_orphan_projection_holder"), @@ -164,6 +165,7 @@ ("pod/runtime_home.py", "orphan_homes"), ("pod/runtime_home.py", "write_pod_config"), ("portability.py", "apply_import_zip"), + ("providers/acp_cli_overlay.py", "_read_cli_overlay_document"), ("sandbox_argv.py", "cleanup_stale_sandbox_profiles"), ("seed.py", "copy_fixture_into_witnessed_dir"), ("seed.py", "copy_fixture_into_witnessed_dir._walk"), @@ -220,6 +222,7 @@ ("snapshot_restore.py", "_refuse_corrupt_source_databases"), ("snapshot_restore.py", "_restore_everything_from_rollback"), ("work_root.py", "sweep_work_root"), - ("workspace_cli_settings.py", "workspace_cli_settings_lock"), + ("workspace_cli_settings.py", "_settings_dir_within_work_dir"), + ("workspace_cli_settings.py", "locked_workspace_cli_settings"), } ) diff --git a/test/session_map_mirror_helpers.py b/test/session_map_mirror_helpers.py new file mode 100644 index 00000000000..be6b3553c2c --- /dev/null +++ b/test/session_map_mirror_helpers.py @@ -0,0 +1,16 @@ +"""The SessionMap fixture shared by the ``test_session_map_mirror*.py`` modules.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from kiro_crew.session_map import SessionMap + + +@pytest.fixture(name="session_map") +def session_map_fixture(tmp_path): + """A SessionMap backed by a temp directory.""" + with patch("kiro_crew.session_map.config_dir", return_value=tmp_path): + yield SessionMap() diff --git a/test/test_acp_bindings.py b/test/test_acp_bindings.py index 187ca3c02f1..6a34abb6af6 100644 --- a/test/test_acp_bindings.py +++ b/test/test_acp_bindings.py @@ -46,32 +46,59 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``). The in "__init__": ("function", False, "22e43ccb505a", "acp"), "_abc_impl": ("data", False, "", ""), "_advertised_effort_levels": ("function", False, "93b23ca4342b", "acp_model_effort"), - "_apply_effort_overlay": ("function", False, "66550c0d40fa", "acp_cli_overlay"), + "_apply_effort_overlay": ("function", False, "921818e23066", "acp_cli_overlay"), "_apply_initial_effort": ("function", True, "b60c5fea256e", "acp_model_effort"), "_apply_tool_search_overlay": ("function", False, "b60c5fea256e", "acp_cli_overlay"), "_bind_unbound_effort": ("function", True, "b60c5fea256e", "acp_model_effort"), "_effort_model": ("property", False, "bb418bb7ece8", "acp"), + "_effort_overlay_fence_mode": ("function", False, "0eaedcb88bad", "acp_cli_overlay"), "_emit_kiro_startup_metric": ("function", False, "4c7cfd604f41", "acp_runtime_start"), "_inline_turn_finished_cleanly": ("function", False, "a6632a57e602", "acp_turns"), "_load_session_with_retry": ("function", True, "cf2f42090789", "acp_runtime_start"), + "_log_effort_overlay_projection_skipped": ( + "function", + False, + "8d14cebaefaa", + "acp_cli_overlay", + ), "_member_session_key": ("function", False, "bb418bb7ece8", "acp_runtime_start"), "_note_zero_tool_servers": ("function", True, "b60c5fea256e", "acp_runtime_start"), "_owning_channel_id": ("function", False, "f21e39ec5ffb", "acp_runtime_start"), "_owning_session_key": ("function", False, "bb418bb7ece8", "acp_runtime_start"), + "_project_explicit_effort_default": ( + "function", + False, + "b60c5fea256e", + "acp_cli_overlay", + ), "_ready_confirmed_session": ("function", True, "b60c5fea256e", "acp_turns"), + "_reassert_projected_effort": ( + "function", + True, + "a6632a57e602", + "acp_model_effort", + ), "_reclaim_work_dir": ("function", True, "b60c5fea256e", "acp_lifecycle"), "_resolve_effort": ("function", False, "f21e39ec5ffb", "acp_model_effort"), "_set_effort_config_option": ("function", True, "b9de7871fd78", "acp_model_effort"), + "_spawn_effort_model": ("property", False, "bb418bb7ece8", "acp_cli_overlay"), "_start_kiro_runtime": ("function", True, "b60c5fea256e", "acp_runtime_start"), "_start_kiro_runtime_impl": ("function", True, "bb38224e64f4", "acp_runtime_start"), "_to_llm_event": ("staticmethod", False, "9400e81ecdfb", "acp_turns"), "_tool_search_settings": ("function", False, "da838953900b", "acp"), + "_write_effort_overlay": ("function", True, "b989a9ec7c8d", "acp_cli_overlay"), "accepts_effort_level": ("function", False, "581451d2b011", "acp_model_effort"), "accepts_elicitation": ("property", False, "a6632a57e602", "acp"), "agent_version": ("property", False, "bb418bb7ece8", "acp"), "answer_elicitation": ("function", True, "2fcb1ac189fa", "acp"), "approval_withdrawable": ("data", False, "", ""), "approve_tool": ("function", True, "d240c0f882a7", "acp"), + "arm_explicit_effort_default": ( + "function", + False, + "cd89b49f47bf", + "acp_cli_overlay", + ), "available_models": ("function", False, "92d6193dbe19", "acp"), "background_launch": ("function", False, "d85112f1c4e4", "acp"), "billing_stats": ("function", False, "8a5937500587", "acp"), @@ -81,7 +108,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``). The in "change_effort": ("function", True, "581451d2b011", "acp_model_effort"), "child_fidelity_aware": ("property", False, "a6632a57e602", "acp"), "cleanup_session": ("function", True, "c284282f8a1d", "acp_lifecycle"), - "clear_effort": ("function", True, "16180c63d5e6", "acp_model_effort"), + "clear_effort": ("function", True, "f90b89af612d", "acp_model_effort"), "client": ("property", False, "4d5584e1d14b", "acp"), "compact": ("function", True, "416a3df991e6", "acp_turns"), "compaction_self_managed": ("property", False, "a6632a57e602", "acp"), @@ -97,6 +124,12 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``). The in "discard_unanswered_prompt": ("function", True, "a6632a57e602", "acp"), "disown_work_dir": ("function", False, "b60c5fea256e", "acp_lifecycle"), "exit_code": ("property", False, "8dc15be1dfda", "acp"), + "explicit_effort_default_applied": ( + "property", + False, + "a6632a57e602", + "acp_cli_overlay", + ), "get_valid_effort_levels": ("function", False, "6806e867af4e", "acp"), "harness_managed_compaction_backend": ("property", False, "f21e39ec5ffb", "acp"), "has_active_turn": ("function", False, "a6632a57e602", "acp"), diff --git a/test/test_acp_cli_overlay_bom.py b/test/test_acp_cli_overlay_bom.py index a56e80241ba..46525aae76e 100644 --- a/test/test_acp_cli_overlay_bom.py +++ b/test/test_acp_cli_overlay_bom.py @@ -1,10 +1,10 @@ -"""The workspace ``cli.json`` overlay readers accept a UTF-8 byte-order mark. +"""The workspace ``cli.json`` overlay reader accepts a UTF-8 byte-order mark. ``/.kiro/settings/cli.json`` can be a person's own workspace settings file, and Windows editors save UTF-8 "with BOM" by default. ``json.loads`` on -the decoded text refuses that mark, and the two overlay writers treat a refused -file as empty, so a reader that kept the mark would replace a BOM-saved file -with one holding only Kiro Crew's keys on the next spawn. +the decoded text refuses that mark, and a file that does not parse is left +unchanged while the overlay write fails, so a reader that kept the mark would +stop every effort and Tool Search overlay from applying in that workspace. """ from __future__ import annotations @@ -14,7 +14,6 @@ from kiro_crew.providers.acp import ( _clear_cli_overlay_effort, - _read_cli_overlay, _write_cli_overlay, _write_tool_search_overlay, ) @@ -55,10 +54,9 @@ def test_effort_overlay_keeps_the_user_settings(tmp_path): assert data["chat.modelDefaults"]["claude-sonnet-5"]["output_config"]["effort"] == "high" -def test_effort_is_recovered_and_cleared(tmp_path): +def test_an_explicit_clear_removes_the_level_and_keeps_the_settings(tmp_path): _write_bom_settings(tmp_path) - assert _read_cli_overlay(tmp_path) == {"claude-opus-5": "low"} - assert _clear_cli_overlay_effort(tmp_path, "claude-opus-5") is True + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-5", owned_only=False) is True data = _on_disk(tmp_path) assert data["chat.defaultModel"] == "claude-opus-5" assert "claude-opus-5" not in data.get("chat.modelDefaults", {}) diff --git a/test/test_acp_kas_effort.py b/test/test_acp_kas_effort.py index c27ec23e253..5c8df12d617 100644 --- a/test/test_acp_kas_effort.py +++ b/test/test_acp_kas_effort.py @@ -116,6 +116,21 @@ async def test_clearing_to_a_workspace_default_uses_the_config_option() -> None: provider._client.send_command.assert_not_awaited() +@pytest.mark.asyncio +async def test_the_post_spawn_reassert_leaves_kas_to_its_config_option() -> None: + """A KAS start pushes its level once, as ``effortLevel``, and never ``/effort``. + + ``start()`` runs the kiro-cli ``/effort`` reassert before the config-option + push. KAS answers that command with -32603, so the reassert reads the + slash-command capability and skips KAS. + """ + provider = _kas_provider(override="xhigh") + await provider._reassert_projected_effort() + await provider._apply_initial_effort() + provider._client.send_command.assert_not_awaited() + provider._client.set_config_option.assert_awaited_once_with("effortLevel", "xhigh") + + @pytest.mark.asyncio async def test_the_handle_adopts_the_options_a_config_write_returns() -> None: """``effortLevel`` appears only in the ``model`` write's result. diff --git a/test/test_acp_provider.py b/test/test_acp_provider.py index 1624efe6238..a719f574fec 100644 --- a/test/test_acp_provider.py +++ b/test/test_acp_provider.py @@ -815,8 +815,9 @@ async def test_kiro_clear_to_default_does_not_push_when_the_overlay_write_fails( async def test_kiro_effort_rollback_follows_the_file_when_the_rewrite_fails(self): # The live push failed, so change_effort rolls the prior level back and # rewrites the overlay to match. When THAT rewrite also fails the file - # keeps the attempted level and construction re-seeds from it, so the map - # follows the file instead of reporting an undo the next spawn contradicts. + # keeps the attempted level, which is the level the session was asked to + # run, so the map follows the file and the next spawn projects it back + # instead of the rollback reporting an undo the file does not show. from kiro_crew.acp.client import AcpError provider = self._effort_provider(backend="", model="claude-opus-4.7") @@ -883,6 +884,33 @@ async def test_kiro_change_effort_gpt_uses_reasoning_overlay_key(self): wco.assert_called_once() assert provider._effort_per_model["gpt-5.6-luna"] == "max" + @pytest.mark.asyncio + @pytest.mark.parametrize( + "model,overrides,expected_level", + [ + ("claude-opus-4.7", {"claude-opus-4.7": "high"}, "high"), + ("auto", {"auto": "high"}, None), + ("claude-opus-4.7", {}, None), + ], + ids=["concrete-level", "auto", "no-level"], + ) + async def test_new_conversation_reasserts_only_a_concrete_resolved_kiro_level( + self, model, overrides, expected_level + ): + provider = self._effort_provider(backend="", model=model) + provider._effort_per_model = overrides + provider._client.new_conversation = AsyncMock() + + await provider.new_conversation() + + provider._client.new_conversation.assert_awaited_once() + if expected_level is None: + provider._client.send_command.assert_not_awaited() + else: + provider._client.send_command.assert_awaited_once_with( + "/effort", args={"level": expected_level} + ) + def test_supports_effort_reflects_model(self): assert self._effort_provider(backend="", model="claude-opus-4.7").supports_effort() # GPT models are effort-capable on the kiro backend. diff --git a/test/test_acp_set_mode_projection_fallback.py b/test/test_acp_set_mode_projection_fallback.py index f115504e485..7726ec9c926 100644 --- a/test/test_acp_set_mode_projection_fallback.py +++ b/test/test_acp_set_mode_projection_fallback.py @@ -511,7 +511,7 @@ async def test_a_busy_settings_lock_blames_no_process( def busy_settings_lock(work_dir): raise OSError("workspace CLI settings lock still held after 5s") - monkeypatch.setattr(skill_projection, "workspace_cli_settings_lock", busy_settings_lock) + monkeypatch.setattr(skill_projection, "locked_workspace_cli_settings", busy_settings_lock) rt, kiro = _runtime({FRESH_ALIAS}) with pytest.raises(AcpRuntimeError) as refused: diff --git a/test/test_acp_tool_search.py b/test/test_acp_tool_search.py index fa13328afba..04e65586d28 100644 --- a/test/test_acp_tool_search.py +++ b/test/test_acp_tool_search.py @@ -11,11 +11,16 @@ from __future__ import annotations import json -from unittest.mock import MagicMock, patch +import logging +import os +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from conftest import requires_symlinks from kiro_crew.acp.types import ACP_BACKEND_CLAUDE +from kiro_crew.providers import acp as acp_provider from kiro_crew.providers.acp import ( TOOL_SEARCH_DEFAULT_MIN_PCT, TOOL_SEARCH_DEFAULT_MIN_TOKENS, @@ -24,6 +29,8 @@ _write_tool_search_overlay, ) +pytestmark = pytest.mark.usefixtures("no_zero_tool_check") + def _build_provider(backend: str) -> AcpProvider: """Build an AcpProvider with a mocked client (mirrors test_acp_provider.py).""" @@ -86,13 +93,74 @@ def test_effort_write_after_tool_search_preserves_both(self, tmp_path): == "xhigh" ) - def test_handles_corrupt_existing_json(self, tmp_path): + @pytest.mark.parametrize( + "contents", + [b"{ this is not valid json", b'{"x": "\xff"}', b"[1, 2]"], + ids=["malformed", "undecodable", "non-object"], + ) + def test_unmergeable_existing_file_raises_and_is_left_unchanged(self, tmp_path, contents): + # Resetting the file to {} to make room for the flag would destroy the + # operator's settings and every other session's effort entries. The + # same guard as _write_cli_overlay: the bytes stay, the write raises. cli = _cli_json(tmp_path) cli.parent.mkdir(parents=True, exist_ok=True) - cli.write_text("{ this is not valid json", encoding="utf-8") + cli.write_bytes(contents) + + with pytest.raises(ValueError, match="left unchanged"): + _write_tool_search_overlay(tmp_path, True) + + assert cli.read_bytes() == contents + + def test_unreadable_existing_file_raises_the_read_error(self, tmp_path, monkeypatch): _write_tool_search_overlay(tmp_path, True) - data = json.loads(cli.read_text(encoding="utf-8")) - assert data["toolSearch.enabled"] is True + cli = _cli_json(tmp_path) + before = cli.read_bytes() + real_read = acp_provider.safe_read_file_bytes_nolink + + def _flaky_read(raw, *args, **kwargs): + if Path(raw).name == "cli.json": + raise OSError("sharing violation") + return real_read(raw, *args, **kwargs) + + def _failed_pinned_read(_settings_fd, _cli_json): + raise OSError("sharing violation") + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "safe_read_file_bytes_nolink", _flaky_read) + temporary_patches.setattr(acp_provider, "_read_pinned_cli_json", _failed_pinned_read) + with pytest.raises(OSError, match="sharing violation"): + _write_tool_search_overlay(tmp_path, False) + + assert cli.read_bytes() == before + + @pytest.mark.parametrize( + "link_kind", [pytest.param("symlink", marks=requires_symlinks), "hardlink"] + ) + def test_linked_existing_file_is_refused_without_copying_contents(self, tmp_path, link_kind): + cli = _cli_json(tmp_path) + cli.parent.mkdir(parents=True) + target = tmp_path / "outside.json" + secret = b'{"token": "SECRET"}' + target.write_bytes(secret) + try: + if link_kind == "symlink": + cli.symlink_to(target) + else: + os.link(target, cli) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"{link_kind} is unavailable: {exc}") + + with pytest.raises(OSError, match="link|hardlink|regular"): + _write_tool_search_overlay(tmp_path, True) + + assert target.read_bytes() == secret + assert not any( + path != cli + and path.is_file() + and not path.is_symlink() + and b"SECRET" in path.read_bytes() + for path in cli.parent.iterdir() + ) def test_idempotent(self, tmp_path): _write_tool_search_overlay(tmp_path, True) @@ -101,6 +169,75 @@ def test_idempotent(self, tmp_path): second = _cli_json(tmp_path).read_text(encoding="utf-8") assert first == second + @staticmethod + def _stamp_clock(monkeypatch): + """Drive the stamp clock by hand, so a repeat write would take a different stamp.""" + from types import SimpleNamespace + + from kiro_crew import workspace_cli_settings + + clock = [1_790_000_000.0] + monkeypatch.setattr(workspace_cli_settings, "time", SimpleNamespace(time=lambda: clock[0])) + + def advance(seconds): + clock[0] += seconds + + return advance + + def test_repeat_write_over_an_owned_effort_leaves_bytes_and_mtime_unchanged( + self, tmp_path, monkeypatch + ): + advance = self._stamp_clock(monkeypatch) + _write_cli_overlay(tmp_path, "claude-opus-4.7", "high") + _write_tool_search_overlay(tmp_path, True) + cli = _cli_json(tmp_path) + before = cli.read_bytes() + before_stat = cli.stat() + advance(10) + + _write_tool_search_overlay(tmp_path, True) + + after_stat = cli.stat() + assert cli.read_bytes() == before + assert (after_stat.st_ino, after_stat.st_mtime_ns) == ( + before_stat.st_ino, + before_stat.st_mtime_ns, + ) + data = json.loads(before) + assert data["kirocrew.effortOwned"] == {"claude-opus-4.7": "high"} + assert data["kirocrew.effortOwnedStamp"] == int(after_stat.st_mtime) + + def test_repeat_write_with_no_record_leaves_bytes_unchanged(self, tmp_path, monkeypatch): + advance = self._stamp_clock(monkeypatch) + _write_tool_search_overlay(tmp_path, True) + cli = _cli_json(tmp_path) + before = cli.read_bytes() + advance(10) + + _write_tool_search_overlay(tmp_path, True) + + assert cli.read_bytes() == before + + @pytest.mark.parametrize( + "contents", + [ + pytest.param(None, id="absent"), + pytest.param(b'{"unrelated.key": 42}', id="operator"), + pytest.param(b'{"kirocrew.effortOwnedStamp": 1790000000}', id="stray-stamp"), + ], + ) + def test_write_with_no_record_stores_no_kirocrew_key(self, tmp_path, contents): + cli = _cli_json(tmp_path) + if contents is not None: + cli.parent.mkdir(parents=True) + cli.write_bytes(contents) + + _write_tool_search_overlay(tmp_path, True) + + data = json.loads(cli.read_text(encoding="utf-8")) + assert data["toolSearch.enabled"] is True + assert not [key for key in data if key.startswith("kirocrew.")] + # ── Provider application logic ─────────────────────────────────────────────── @@ -137,25 +274,57 @@ def test_none_value_skips(self, tmp_path): provider._apply_tool_search_overlay() assert not _cli_json(tmp_path).exists() + @pytest.mark.asyncio + async def test_unmergeable_file_survives_construction_and_start( + self, tmp_path, monkeypatch, caplog + ): + # A corrupt overlay must never stop a session from starting, and the + # refused write must be visible: the file keeps its bytes and the + # provider logs the skip instead of raising into __init__ or start(). + cli = _cli_json(tmp_path) + cli.parent.mkdir(parents=True, exist_ok=True) + contents = b"{ this is not valid json" + cli.write_bytes(contents) + + with caplog.at_level(logging.WARNING, logger="kiro_crew.providers.acp"): + provider = AcpProvider(acp_backend="", work_dir=tmp_path, tool_search=True) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + await provider.start() + + assert cli.read_bytes() == contents + assert "tool-search overlay write failed" in caplog.text + # ── Constructor wiring ─────────────────────────────────────────────────────── class TestInitWiring: @pytest.mark.parametrize( - ("backend", "applied"), + "backend", [ - pytest.param("", 1, id="kiro_enabled_applies_on_init"), - pytest.param(ACP_BACKEND_CLAUDE, 0, id="claude_backend_does_not_apply_on_init"), + pytest.param("", id="kiro_does_not_apply_on_init"), + pytest.param(ACP_BACKEND_CLAUDE, id="claude_backend_does_not_apply_on_init"), ], ) - def test_overlay_on_init(self, backend, applied): + def test_overlay_on_init(self, backend): with patch("kiro_crew.providers.acp.AcpClient") as mock_client, patch.object( AcpProvider, "_apply_tool_search_overlay" ) as ats: mock_client.return_value.backend = backend AcpProvider(acp_backend=backend, tool_search=True) - assert ats.call_count == applied + ats.assert_not_called() + + @pytest.mark.asyncio + async def test_kiro_applies_tool_search_only_during_start(self, tmp_path, monkeypatch): + provider = AcpProvider(acp_backend="", work_dir=tmp_path, tool_search=True) + + assert not _cli_json(tmp_path).exists() + + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + await provider.start() + + data = json.loads(_cli_json(tmp_path).read_text(encoding="utf-8")) + assert data["toolSearch.enabled"] is True # ── Config plumbing ────────────────────────────────────────────────────────── diff --git a/test/test_atomic_write_pinned_parent.py b/test/test_atomic_write_pinned_parent.py index cd48dacf81b..babebb2e8a9 100644 --- a/test/test_atomic_write_pinned_parent.py +++ b/test/test_atomic_write_pinned_parent.py @@ -197,6 +197,30 @@ def test_parent_dir_fd_none_preserves_the_by_name_behaviour(tmp_path): assert list(tmp_path.glob("*.tmp")) == [] +@pytest.mark.parametrize("pinned", [False, True], ids=["by-name", "parent-dir-fd"]) +def test_requested_mtime_is_carried_by_the_published_inode(tmp_path, pinned): + """The staged inode receives the requested mtime before either rename path.""" + target = tmp_path / "doc.md" + requested_mtime_ns = 1_700_000_000 * 1_000_000_000 + dir_fd = None + if pinned: + _needs_pinned_parent() + dir_fd = _pin(tmp_path) + try: + atomic_write( + target, + "timestamped body", + parent_dir_fd=dir_fd, + mtime_ns=requested_mtime_ns, + ) + finally: + if dir_fd is not None: + os.close(dir_fd) + + assert target.read_text(encoding="utf-8") == "timestamped body" + assert target.stat().st_mtime_ns == requested_mtime_ns + + def test_a_pinned_write_stages_through_the_descriptor_not_mkstemp(tmp_path, monkeypatch): """With a parent_dir_fd the temp is created via _mkstemp_at, never tempfile.mkstemp. diff --git a/test/test_backend_cards_advertised.py b/test/test_backend_cards_advertised.py index d1d2daae0fc..5b42cec532c 100644 --- a/test/test_backend_cards_advertised.py +++ b/test/test_backend_cards_advertised.py @@ -68,7 +68,6 @@ ("dashboard/chat_turn/slash_commands.py", "ACP_BACKENDS_COMPACT"): 1, ("deny_notice.py", "ACP_BACKENDS_STEER"): 1, ("providers/acp.py", "ACP_BACKENDS_COMPACT"): 2, - ("providers/acp.py", "ACP_BACKENDS_KIRO_FAMILY"): 1, ("providers/acp.py", "ACP_BACKENDS_STEER"): 1, ("providers/acp.py", "ACP_BACKENDS_STEERING_REQUEST"): 2, ("providers/acp_model_effort.py", "ACP_BACKENDS_KIRO_FAMILY"): 2, diff --git a/test/test_chat_handlers_composition_contract.py b/test/test_chat_handlers_composition_contract.py index 9d61ec1103b..151e17006b6 100644 --- a/test/test_chat_handlers_composition_contract.py +++ b/test/test_chat_handlers_composition_contract.py @@ -787,6 +787,8 @@ def test_no_handler_module_reaches_the_conductor_wake() -> None: slot_approval | _deny_trust_pattern | function | (name: 'str', request_id: 'str', action: 'str', code: 'str') -> 'web.Response' slot_approval | api_chat_slot_approve | async function | (request: 'web.Request') -> 'web.Response' slot_effort | _configured_backend_for_slot | async function | (slot: '_ChatSlot') -> 'str' +slot_effort | _effort_intent_unsaved | function | (slot: '_ChatSlot', effort: 'str', *, normalized_model: 'str | None' = None, rebind_warning: 'str | None' = None) -> 'web.Response' +slot_effort | _bump_session_effort_intent_slots | function | (state: 'DashboardState', session_key: 'str') -> 'None' slot_effort | api_chat_slot_selection_capabilities | async function | (request: 'web.Request') -> 'web.Response' slot_effort | api_chat_slot_reasoning_effort | async function | (request: 'web.Request') -> 'web.Response' slot_guards | _record_explicit_agent_selection | async function | (session_key: 'str', agent_name: 'str | None', bindings: 'ResolvedBindings', *, config: 'KiroCrewConfig', memory_mode: 'str' = 'persistent', app: 'str' = '') -> 'SelectionChange | None' diff --git a/test/test_chat_slot_reasoning_effort.py b/test/test_chat_slot_reasoning_effort.py index 40a78dc858c..dee7fbb445c 100644 --- a/test/test_chat_slot_reasoning_effort.py +++ b/test/test_chat_slot_reasoning_effort.py @@ -7,42 +7,14 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from aiohttp import web from aiohttp.test_utils import TestClient, TestServer +from chat_slot_reasoning_effort_helpers import _make_app, _mock_state from kiro_crew.dashboard import chat_handlers, chat_persistence -from kiro_crew.dashboard.chat import api_chat_slot_reasoning_effort -from kiro_crew.dashboard.chat_handlers import api_chat_slot_selection_capabilities -from kiro_crew.dashboard.state import DashboardState, _ChatSlot +from kiro_crew.dashboard.state import _ChatSlot from kiro_crew.providers.acp import AcpProvider -def _make_app(state: DashboardState) -> web.Application: - app = web.Application() - app["state"] = state - app.router.add_post( - "/api/chat/slots/{slot}/reasoning-effort", api_chat_slot_reasoning_effort - ) - app.router.add_get( - "/api/chat/slots/{slot}/selection-capabilities", api_chat_slot_selection_capabilities - ) - return app - - -def _mock_state(slot: _ChatSlot | None = None, provider: object = None) -> DashboardState: - state = MagicMock(spec=DashboardState) - state._slots = {} - if slot: - state._slots[slot.key] = slot - state.push_slots_update = MagicMock() - state.sessions = MagicMock() - state.sessions.reset = AsyncMock() - # No live AcpProvider by default → handler falls back to session reset - # (matches prior behaviour for the "no session yet" path). - state.sessions.get_provider = MagicMock(return_value=provider) - return state - - class TestSlotSelectionCapabilities: @pytest.mark.asyncio async def test_live_effort_levels_use_the_shared_cap(self, monkeypatch, caplog): @@ -302,9 +274,7 @@ async def test_cold_member_effort_clear_uses_member_backend_for_legacy_pair( chat_handlers.KiroCrewConfig, "load", lambda: SimpleNamespace( - agent=SimpleNamespace( - acp_backend=global_backend, member_acp_backend=member_backend - ) + agent=SimpleNamespace(acp_backend=global_backend, member_acp_backend=member_backend) ), ) slot = _ChatSlot("member-test") @@ -515,15 +485,19 @@ async def _reset(*args, **kwargs): state.sessions.reset = AsyncMock(side_effect=_reset) async with TestClient(TestServer(_make_app(state))) as client: - first = asyncio.create_task(client.post( - "/api/chat/slots/test/reasoning-effort", - json={"reasoning_effort": "low"}, - )) + first = asyncio.create_task( + client.post( + "/api/chat/slots/test/reasoning-effort", + json={"reasoning_effort": "low"}, + ) + ) await first_reset_started.wait() - second = asyncio.create_task(client.post( - "/api/chat/slots/test/reasoning-effort", - json={"reasoning_effort": "low"}, - )) + second = asyncio.create_task( + client.post( + "/api/chat/slots/test/reasoning-effort", + json={"reasoning_effort": "low"}, + ) + ) # Let the second request reach (and block on) the slot lock, then # let the first request's reset fail. await asyncio.sleep(0.05) @@ -729,7 +703,11 @@ async def test_live_clear_that_changed_nothing_commits_nothing_and_does_not_rese json={"reasoning_effort": ""}, ) assert resp.status == 409 - assert (await resp.json())["code"] == "effort_overlay_busy" + response = await resp.json() + assert response["code"] == "effort_overlay_busy" + assert "1 MiB" in response["error"] + assert "fenced by a spawning session" in response["error"] + assert "a link or in a folder outside the work dir" in response["error"] assert slot.reasoning_effort == "high", "the cleared value was committed anyway" state.sessions.reset.assert_not_called() @@ -752,7 +730,11 @@ async def test_live_clear_applied_live_skips_reset(self): json={"reasoning_effort": ""}, ) assert resp.status == 200 - provider.clear_effort.assert_awaited_once() + # The rewrite runs inside the warm pool's fence, so no runtime queued + # before it is claimed after it. + provider.clear_effort.assert_awaited_once_with( + owned_only=False, fence_rewrite=state.sessions.fence_effort_overlay_rewrite + ) state.sessions.reset.assert_not_called() # Cleared live: the record is dropped, so a successor starts on the default. state.sessions.record_effort_override.assert_called_once_with( @@ -779,7 +761,11 @@ async def test_live_clear_not_applied_live_falls_back_to_reset(self): json={"reasoning_effort": ""}, ) assert resp.status == 200 - provider.clear_effort.assert_awaited_once() + # The reset's next start may claim a warm runtime, and the fence the + # rewrite ran inside keeps one queued before it from being claimed. + provider.clear_effort.assert_awaited_once_with( + owned_only=False, fence_rewrite=state.sessions.fence_effort_overlay_rewrite + ) state.sessions.reset.assert_awaited_once() state.sessions.record_effort_override.assert_not_called() @@ -804,6 +790,28 @@ async def test_non_effort_capable_model_persists_without_live_or_reset(self): provider.change_effort.assert_not_awaited() state.sessions.reset.assert_not_called() + @pytest.mark.asyncio + async def test_live_persistence_fence_timeout_falls_back_to_reset_and_commits_slot(self): + provider = MagicMock(spec=AcpProvider) + provider.supports_effort = MagicMock(return_value=True) + provider.has_active_turn = MagicMock(return_value=False) + provider.change_effort = AsyncMock( + side_effect=RuntimeError( + "could not persist effort 'max' for 'claude-opus-4.7': the workspace overlay is locked" + ) + ) + slot = _ChatSlot("test") + state = _mock_state(slot, provider=provider) + + async with TestClient(TestServer(_make_app(state))) as client: + response = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": "max"} + ) + + assert response.status == 200 + assert slot.reasoning_effort == "max" + state.sessions.reset.assert_awaited_once() + @pytest.mark.asyncio async def test_live_change_failure_falls_back_to_reset(self): from kiro_crew.providers.acp import AcpProvider @@ -837,6 +845,7 @@ async def test_active_turn_defers_live_push(self): provider.change_effort = AsyncMock(return_value=True) provider.clear_effort = AsyncMock(return_value=True) slot = _ChatSlot("test") + slot._effort_intent_owed = True state = _mock_state(slot, provider=provider) async with TestClient(TestServer(_make_app(state))) as client: resp = await client.post( @@ -848,6 +857,7 @@ async def test_active_turn_defers_live_push(self): assert data == {"ok": True, "reasoning_effort": "xhigh", "deferred": True} # Persisted on the slot for the next turn. assert slot.reasoning_effort == "xhigh" + assert slot._effort_intent_owed is False # No live push and no reset while the turn is active. provider.change_effort.assert_not_awaited() provider.clear_effort.assert_not_awaited() @@ -911,6 +921,7 @@ class TestValidateReasoningEffortPersistence: @pytest.mark.parametrize("level", ["", "low", "medium", "high", "xhigh", "max"]) def test_passes_through_allowlisted(self, level: str): from kiro_crew.dashboard.chat_persistence import _validate_reasoning_effort + assert _validate_reasoning_effort(level) == level @pytest.mark.parametrize( @@ -919,10 +930,12 @@ def test_passes_through_allowlisted(self, level: str): ) def test_discards_disallowed(self, tampered: str): from kiro_crew.dashboard.chat_persistence import _validate_reasoning_effort + assert _validate_reasoning_effort(tampered) == "" def test_discards_non_string(self): from kiro_crew.dashboard.chat_persistence import _validate_reasoning_effort + assert _validate_reasoning_effort(5) == "" assert _validate_reasoning_effort(None) == "" assert _validate_reasoning_effort(["max"]) == "" diff --git a/test/test_chat_slot_reasoning_effort_explicit_default.py b/test/test_chat_slot_reasoning_effort_explicit_default.py new file mode 100644 index 00000000000..0953d29cfd8 --- /dev/null +++ b/test/test_chat_slot_reasoning_effort_explicit_default.py @@ -0,0 +1,1410 @@ +"""Reasoning-effort endpoint tests: the explicit Default intent a request leaves behind. + +An explicit Default the request could not apply itself is recorded on the +session key for its next cold start; no answer reports success after a failed +intent write, and the same pick retries an owed write. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest +from aiohttp.test_utils import TestClient, TestServer +from chat_slot_reasoning_effort_helpers import _make_app, _mock_state + +from kiro_crew.dashboard import chat_handlers, chat_persistence +from kiro_crew.dashboard.chat_api import slot_effort +from kiro_crew.dashboard.state import DashboardState, _ChatSlot +from kiro_crew.providers.acp import AcpProvider +from kiro_crew.testing.wait import default_timeout + + +async def _await_test(awaitable, what: str): + try: + async with asyncio.timeout(default_timeout()) as deadline: + return await awaitable + except TimeoutError as exc: + if not deadline.expired(): + raise + raise AssertionError(f"timed out waiting for {what}") from exc + + +class TestExplicitDefaultIntent: + """An explicit Default the request could not apply itself reaches the next cold start. + + Only an explicit Default may remove a workspace effort entry Kiro Crew did + not write. When the pick cannot do that itself -- no live session, a turn in + flight, a live clear that raised, a model without effort -- the handler + records the intent on the session key, and the key's next cold start + applies it before spawning. + """ + + @staticmethod + def _live(**overrides: object) -> MagicMock: + provider = MagicMock(spec=AcpProvider) + provider.supports_effort = MagicMock(return_value=True) + provider.has_active_turn = MagicMock(return_value=False) + for name, value in overrides.items(): + setattr(provider, name, value) + return provider + + @staticmethod + async def _pick(state: DashboardState, effort: str) -> int: + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": effort} + ) + return resp.status + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("target_key", "alias_key"), + [ + ("slack:1700000000.000100", "1700000000.000100"), + ("1700000000.000100", "slack:1700000000.000100"), + ], + ) + async def test_a_saved_change_bumps_every_open_slot_on_the_folded_session( + self, target_key, alias_key + ): + target = _ChatSlot("test") + target.linked_session_key = target_key + target.reasoning_effort = "high" + alias = _ChatSlot("alias") + alias.linked_session_key = alias_key + other = _ChatSlot("other") + other.linked_session_key = "slack:1700000000.000200" + state = _mock_state(target) + state._slots.update({alias.key: alias, other.key: other}) + + assert await self._pick(state, "") == 200 + + assert target._session_effort_intent_gen == 1 + assert alias._session_effort_intent_gen == 1 + assert other._session_effort_intent_gen == 0 + + def test_effort_intent_state_is_bounded_by_open_slots(self): + from kiro_crew.session import SessionManager + + target = _ChatSlot("test") + target.linked_session_key = "slack:1700000000.000100" + alias = _ChatSlot("alias") + alias.linked_session_key = "1700000000.000100" + other = _ChatSlot("other") + other.linked_session_key = "slack:1700000000.000200" + state = _mock_state(target) + state._slots.update({alias.key: alias, other.key: other}) + + for index in range(2_000): + slot_effort._bump_session_effort_intent_slots(state, f"slack:1800000000.{index:06d}") + slot_effort._bump_session_effort_intent_slots(state, target.linked_session_key) + + assert target._session_effort_intent_gen == 1 + assert alias._session_effort_intent_gen == 1 + assert other._session_effort_intent_gen == 0 + assert {name for name in vars(SessionManager) if name.startswith("effort_intent_")} == { + "effort_intent_write" + } + + @pytest.mark.asyncio + @pytest.mark.parametrize("reset_verdict", [True, False]) + async def test_a_stopped_chat_leaves_the_default_for_its_next_cold_start(self, reset_verdict): + # No live session, so nothing can remove the entry now. Recorded before + # the reset (a message landing mid-reset cold-starts from it) and still + # set when the request answers, whether or not the reset tore anything + # down. + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + seen_at_reset: list[bool | None] = [] + + async def reset(*_args, **_kwargs): + seen_at_reset.append(state.sessions.default_intents.get(key)) + return reset_verdict + + state.sessions.reset = AsyncMock(side_effect=reset) + + assert await self._pick(state, "") == 200 + assert seen_at_reset == [True] + assert state.sessions.default_intents == {key: True} + state.sessions.aflush.assert_awaited() + + @pytest.mark.asyncio + async def test_a_cold_start_during_the_reset_spends_the_intent_for_good(self): + # A message landing mid-reset cold-starts from the committed Default, + # applies it and clears it. Recording it again once the reset returns + # would make a later cold start remove an entry written since. + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + + async def reset(*_args, **_kwargs): + state.sessions.default_intents[key] = False + return True + + state.sessions.reset = AsyncMock(side_effect=reset) + + assert await self._pick(state, "") == 200 + assert state.sessions.default_intents == {key: False} + + @pytest.mark.asyncio + async def test_a_level_pick_spends_a_pending_default(self): + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + + assert await self._pick(state, "high") == 200 + assert state.sessions.default_intents == {key: False} + + @pytest.mark.asyncio + @pytest.mark.parametrize("applied_live", [True, False]) + async def test_a_live_clear_leaves_nothing_for_a_cold_start(self, applied_live): + # True applied the default live; False rewrote the file and asks for a + # reset. Either way this request removed the entry itself. + provider = self._live(clear_effort=AsyncMock(return_value=applied_live)) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + + assert await self._pick(state, "") == 200 + provider.clear_effort.assert_awaited_once_with( + owned_only=False, fence_rewrite=state.sessions.fence_effort_overlay_rewrite + ) + assert state.sessions.reset.await_count == (0 if applied_live else 1) + assert state.sessions.default_intents == {key: False} + + @pytest.mark.asyncio + async def test_a_live_clear_that_asks_for_a_reset_needs_no_new_row(self): + # Its rewrite landed and only the live push did not (False), so the pick + # records no Default: a full explicit-Default bound cannot refuse a pick + # that already changed the file and the provider. + provider = self._live(clear_effort=AsyncMock(return_value=False)) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + record = state.sessions.set_explicit_effort_default.side_effect + + def refuse_every_new_row(session_key: str, pending: bool) -> bool: + return False if pending else record(session_key, pending) + + state.sessions.set_explicit_effort_default.side_effect = refuse_every_new_row + + assert await self._pick(state, "") == 200 + assert slot.reasoning_effort == "" + state.sessions.reset.assert_awaited_once() + assert state.sessions.default_intents == {key: False} + + @pytest.mark.asyncio + async def test_a_live_clear_that_raised_leaves_the_default_for_the_next_cold_start(self): + provider = self._live(clear_effort=AsyncMock(side_effect=RuntimeError("boom"))) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + + assert await self._pick(state, "") == 200 + state.sessions.reset.assert_awaited_once() + assert state.sessions.default_intents == {key: True} + + @pytest.mark.asyncio + @pytest.mark.parametrize(("effort", "pending"), [("", True), ("high", False)]) + async def test_a_deferred_pick_records_the_intent(self, effort, pending): + # The push waits for the turn, and a live session on Default is pushed + # nothing, so only a cold start can remove the entry. + provider = self._live(has_active_turn=MagicMock(return_value=True)) + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + + assert await self._pick(state, effort) == 200 + assert state.sessions.default_intents == {key: pending} + assert slot._session_effort_intent_gen == int(pending) + + @pytest.mark.asyncio + async def test_a_model_without_effort_leaves_the_default_for_the_next_cold_start(self): + provider = self._live(supports_effort=MagicMock(return_value=False)) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + + assert await self._pick(state, "") == 200 + state.sessions.reset.assert_not_called() + assert state.sessions.default_intents == {key: True} + + @pytest.mark.asyncio + async def test_an_overlay_busy_refusal_records_nothing(self): + provider = self._live(clear_effort=AsyncMock(return_value=None)) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot, provider) + + assert await self._pick(state, "") == 409 + state.sessions.set_explicit_effort_default.assert_not_called() + + @pytest.mark.asyncio + async def test_a_refused_intent_returns_503_without_changing_the_slot(self): + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + state.sessions.set_explicit_effort_default = MagicMock(return_value=False) + + assert await self._pick(state, "") == 503 + assert slot.reasoning_effort == "high" + assert state.sessions.default_intents == {} + state.sessions.aflush.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_pre_pop_reset_raise_restores_the_prior_intent(self): + from kiro_crew.providers.base import LLMProvider + + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + alive = MagicMock(spec=LLMProvider) + alive.has_active_turn.return_value = False + state.sessions.get_provider = MagicMock(return_value=alive) + seen_at_reset: list[bool | None] = [] + + async def reset(*_args, **_kwargs): + seen_at_reset.append(state.sessions.default_intents.get(key)) + raise RuntimeError("pre-pop boom") + + state.sessions.reset = AsyncMock(side_effect=reset) + + assert await self._pick(state, "") == 500 + assert seen_at_reset == [True] + assert slot.reasoning_effort == "high" + assert state.sessions.default_intents == {key: False} + + @pytest.mark.asyncio + async def test_a_turn_in_flight_decline_restores_the_prior_intent(self, monkeypatch): + # The reset declined because a turn slipped in, so nothing switched and + # the value recorded before the reset is taken back. + from kiro_crew.providers.base import LLMProvider + + monkeypatch.setattr(chat_handlers, "_switch_target_busy", lambda *_args: False) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + busy = MagicMock(spec=LLMProvider) + busy.has_active_turn.return_value = True + state.sessions.get_provider = MagicMock(return_value=busy) + state.sessions.reset = AsyncMock(return_value=False) + + assert await self._pick(state, "high") == 409 + assert slot.reasoning_effort == "" + assert state.sessions.default_intents == {key: True} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("effort", "prior_pending", "restore_pending"), + [("", False, False), ("high", True, True)], + ) + async def test_a_reset_refusal_warns_when_rollback_intent_cannot_be_saved( + self, monkeypatch, effort, prior_pending, restore_pending + ): + from kiro_crew.providers.base import LLMProvider + + monkeypatch.setattr(chat_handlers, "_switch_target_busy", lambda *_args: False) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" if not effort else "" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = prior_pending + busy = MagicMock(spec=LLMProvider) + busy.has_active_turn.return_value = True + state.sessions.get_provider = MagicMock(return_value=busy) + state.sessions.reset = AsyncMock(return_value=False) + record_intent = state.sessions.set_explicit_effort_default.side_effect + + def refuse_rollback(key: str, pending: bool) -> bool: + if pending is restore_pending: + return False + return record_intent(key, pending) + + state.sessions.set_explicit_effort_default.side_effect = refuse_rollback + + async with TestClient(TestServer(_make_app(state))) as client: + response = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": effort} + ) + body = await response.json() + + assert response.status == 409 + assert body["warning"] == slot_effort._EFFORT_INTENT_ROLLBACK_NOT_SAVED + + @pytest.mark.asyncio + async def test_a_rollback_keeps_an_intent_a_cold_start_spent_during_the_reset( + self, monkeypatch + ): + # A legacy pair model makes a Default pick on a Default slot reach the + # reset. A cold start during the reset applies the pending Default and + # spends it; the declined reset must not arm it again. + from kiro_crew.providers.base import LLMProvider + + monkeypatch.setattr(chat_handlers, "_switch_target_busy", lambda *_args: False) + monkeypatch.setattr( + chat_handlers, "_configured_backend_for_slot", AsyncMock(return_value="codex") + ) + slot = _ChatSlot("test") + slot.model = "gpt-6-sol[max]" + slot.reasoning_effort = "" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + busy = MagicMock(spec=LLMProvider) + busy.has_active_turn.return_value = True + state.sessions.get_provider = MagicMock(return_value=busy) + + async def reset(*_args, **_kwargs): + state.sessions.default_intents[key] = False + return False + + state.sessions.reset = AsyncMock(side_effect=reset) + + assert await self._pick(state, "") == 409 + assert state.sessions.default_intents == {key: False} + + @pytest.mark.asyncio + async def test_a_rebind_during_the_live_clear_leaves_the_default_on_the_new_binding(self): + # The clear landed on the session the slot WAS bound to. The new + # binding's overlay was never touched, so its next cold start applies + # the Default. + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + + async def clear_effort(**_kwargs): + slot.linked_session_key = "slack:1700000000.000100" + return True + + provider = self._live(clear_effort=AsyncMock(side_effect=clear_effort)) + state = _mock_state(slot, provider) + old_key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[old_key] = True + + assert await self._pick(state, "") == 200 + new_key = chat_handlers.effective_session_key(slot) + assert new_key != old_key + # The clear removed the entry for the old binding, so its pending + # Default is spent there. + assert state.sessions.default_intents == {old_key: False, new_key: True} + + @pytest.mark.asyncio + async def test_a_failed_save_for_the_old_binding_after_a_rebound_push_is_reported(self): + # The new binding's intent is saved, so the slot shows the pick; only the + # binding the push landed on could not be saved, and the answer says so. + slot = _ChatSlot("test") + slot.reasoning_effort = "" + + async def change_effort(_effort): + slot.linked_session_key = "slack:1700000000.000100" + return True + + provider = self._live(change_effort=AsyncMock(side_effect=change_effort)) + state = _mock_state(slot, provider) + state.sessions.aflush = AsyncMock(side_effect=[None, OSError("disk full")]) + + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": "high"} + ) + body = await resp.json() + + assert resp.status == 503 + assert body["code"] == "effort_intent_unsaved" + assert "rebound" in body["warning"] + assert slot.reasoning_effort == "high" + + @pytest.mark.asyncio + async def test_a_rebound_level_the_new_binding_cannot_save_is_refused(self): + # A level clears any Default pending on the new binding, so the slot shows + # it only once that is saved; the refusal first puts the session the push + # reached back on Default with an automatic clear. + slot = _ChatSlot("test") + slot.reasoning_effort = "" + + async def change_effort(_effort): + slot.linked_session_key = "slack:1700000000.000100" + return True + + provider = self._live( + change_effort=AsyncMock(side_effect=change_effort), + clear_effort=AsyncMock(return_value=True), + ) + state = _mock_state(slot, provider) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + assert await self._pick(state, "high") == 503 + assert slot.reasoning_effort == "" + provider.clear_effort.assert_awaited_once_with() + + @pytest.mark.asyncio + async def test_a_rebound_level_does_not_save_the_old_bindings_clear_twice(self): + # The old binding's Default was cleared and saved before the push, so the + # rebind writes only the new binding. + slot = _ChatSlot("test") + slot.reasoning_effort = "" + + async def change_effort(_effort): + slot.linked_session_key = "slack:1700000000.000100" + return True + + provider = self._live(change_effort=AsyncMock(side_effect=change_effort)) + state = _mock_state(slot, provider) + old_key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[old_key] = True + state.sessions.aflush = AsyncMock(side_effect=[None, None, OSError("disk full")]) + + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": "high"} + ) + body = await resp.json() + + new_key = chat_handlers.effective_session_key(slot) + assert resp.status == 200 + assert "warning" in body and not body["warning"].endswith( + slot_effort._EFFORT_INTENT_NOT_SAVED + ) + assert state.sessions.aflush.await_count == 2 + assert state.sessions.default_intents == {old_key: False, new_key: False} + + @pytest.mark.asyncio + @pytest.mark.parametrize("failure", ["refused", "unsaved"]) + async def test_a_rebound_default_the_new_binding_cannot_keep_is_refused(self, failure): + # Nothing reached the new binding, so the slot may show Default only once + # that key's next cold start is sure to apply it; the refusal first puts + # the session the clear reached back on the slot's level. + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + + async def clear_effort(**_kwargs): + slot.linked_session_key = "slack:1700000000.000100" + return True + + provider = self._live( + clear_effort=AsyncMock(side_effect=clear_effort), + change_effort=AsyncMock(return_value=True), + ) + state = _mock_state(slot, provider) + old_key = chat_handlers.effective_session_key(slot) + record = state.sessions.set_explicit_effort_default.side_effect + if failure == "refused": + + def refuse_the_new_binding(key: str, pending: bool) -> bool: + if key != old_key and pending: + return False + return record(key, pending) + + state.sessions.set_explicit_effort_default.side_effect = refuse_the_new_binding + else: + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + assert await self._pick(state, "") == 503 + new_key = chat_handlers.effective_session_key(slot) + assert new_key != old_key + assert slot.reasoning_effort == "high" + assert state.sessions.default_intents.get(new_key, False) is False + provider.change_effort.assert_awaited_once_with("high") + + # The slot still shows the level, so a retry runs the pick again instead + # of stopping at the same-value check. + state.sessions.set_explicit_effort_default.side_effect = record + state.sessions.aflush = AsyncMock() + assert await self._pick(state, "") == 200 + assert slot.reasoning_effort == "" + assert provider.clear_effort.await_count == 2 + + @pytest.mark.asyncio + @pytest.mark.parametrize("undo", ["refused", "raised"]) + async def test_a_rebound_pick_whose_push_cannot_be_undone_is_committed_and_refused(self, undo): + # The session the clear reached cannot be put back, so the pick stands: + # the slot shows what that session runs, and the answer says the new + # binding's intent was not saved. + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + + async def clear_effort(**_kwargs): + slot.linked_session_key = "slack:1700000000.000100" + return True + + change_effort = ( + AsyncMock(return_value=False) + if undo == "refused" + else AsyncMock(side_effect=RuntimeError("rpc failed")) + ) + provider = self._live( + clear_effort=AsyncMock(side_effect=clear_effort), change_effort=change_effort + ) + state = _mock_state(slot, provider) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": ""} + ) + body = await resp.json() + + assert resp.status == 503 + assert body["code"] == "effort_intent_unsaved" + assert "rebound" in body["warning"] + assert slot.reasoning_effort == "" + change_effort.assert_awaited_once_with("high") + + @pytest.mark.asyncio + async def test_a_rebound_pick_that_pushed_nothing_is_refused_without_an_undo(self, monkeypatch): + # The model takes no effort, so no session received the pick and there is + # nothing to put back before refusing. + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + + def rebind(_effort): + slot.linked_session_key = "slack:1700000000.000100" + + monkeypatch.setattr(chat_handlers, "_remember_reasoning_effort_for_restore", rebind) + provider = self._live(supports_effort=MagicMock(return_value=False)) + state = _mock_state(slot, provider) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + assert await self._pick(state, "") == 503 + assert slot.reasoning_effort == "high" + provider.change_effort.assert_not_awaited() + provider.clear_effort.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_cancelled_save_puts_the_prior_value_back(self): + # A pick cancelled while its value was being saved committed nothing, so + # its value must not reach the file with a later save. + import asyncio + + import aiohttp + + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + saved: list[dict[str, bool]] = [] + + async def aflush(): + saved.append(dict(state.sessions.default_intents)) + if len(saved) == 1: + raise asyncio.CancelledError + + state.sessions.aflush = AsyncMock(side_effect=aflush) + + async with TestClient(TestServer(_make_app(state))) as client: + try: + resp = await _await_test( + client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": ""} + ), + "the Default pick request", + ) + except aiohttp.ClientError: + resp = None + assert resp is None or resp.status >= 500 + + assert saved == [{key: True}, {key: False}] + assert state.sessions.default_intents == {key: False} + assert slot.reasoning_effort == "high" + state.sessions.reset.assert_not_called() + + @pytest.mark.asyncio + async def test_a_failed_save_puts_a_pending_intent_back_inside_its_hold(self): + # The pick cleared the key's own pending Default before its save failed, so + # it held that row first and puts the flag back into it before releasing. + slot = _ChatSlot("test") + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + await self._pick(state, "high") + + assert state.sessions.default_intents[key] is True + assert state.sessions.effort_events == [ + ("hold", key), + ("set", key, False), + ("set", key, True), + ("release", key), + ] + + @pytest.mark.asyncio + async def test_a_rolled_back_reset_arms_the_default_again_inside_its_hold(self, monkeypatch): + # The reset declined after the pick saved its cleared intent, so the row + # the pick cleared stays held until the rollback has put the flag back. + from kiro_crew.providers.base import LLMProvider + + monkeypatch.setattr(chat_handlers, "_switch_target_busy", lambda *_args: False) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + busy = MagicMock(spec=LLMProvider) + busy.has_active_turn.return_value = True + state.sessions.get_provider = MagicMock(return_value=busy) + state.sessions.reset = AsyncMock(return_value=False) + + assert await self._pick(state, "high") == 409 + assert state.sessions.default_intents == {key: True} + assert state.sessions.effort_events == [ + ("hold", key), + ("set", key, False), + ("set", key, True), + ("release", key), + ] + + @pytest.mark.asyncio + async def test_a_failed_save_before_anything_changed_refuses_the_pick(self, caplog): + # The saved intent is what the next cold start acts on, so a pick whose + # intent could not be saved is refused before the slot or the session + # changes. + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + assert await self._pick(state, "") == 503 + assert slot.reasoning_effort == "high" + state.sessions.reset.assert_not_called() + assert state.sessions.default_intents == {key: False} + assert slot._session_effort_intent_gen == 0 + assert "Could not save explicit effort Default intent" in caplog.text + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "overrides", + [ + {"has_active_turn": MagicMock(return_value=True)}, + {"supports_effort": MagicMock(return_value=False)}, + ], + ids=["deferred", "model-without-effort"], + ) + async def test_a_failed_save_on_a_live_session_that_was_not_pushed_refuses(self, overrides): + provider = self._live(**overrides) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot, provider) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + assert await self._pick(state, "") == 503 + assert slot.reasoning_effort == "high" + state.sessions.reset.assert_not_called() + + @pytest.mark.asyncio + async def test_a_failed_save_after_a_live_push_commits_and_refuses(self): + # The live session already runs the level, so the slot must show it; the + # file keeps what the last saved pick left, and the answer says so. + provider = self._live(change_effort=AsyncMock(return_value=True)) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": "high"} + ) + body = await resp.json() + + assert resp.status == 503 + assert body["code"] == "effort_intent_unsaved" + assert body["reasoning_effort"] == "high" + assert slot.reasoning_effort == "high" + assert state.sessions.default_intents == {key: False} + + @pytest.mark.asyncio + async def test_a_level_saves_the_cleared_default_before_its_live_push(self): + # A later cold start that takes no level must not find the Default the + # level replaced, so the clear is saved before the push and never again. + slot = _ChatSlot("test") + slot.reasoning_effort = "" + seen_at_push = [] + + async def change_effort(_effort): + seen_at_push.append( + (state.sessions.default_intents.get(key), state.sessions.aflush.await_count) + ) + return True + + provider = self._live(change_effort=AsyncMock(side_effect=change_effort)) + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + + assert await self._pick(state, "high") == 200 + assert seen_at_push == [(False, 1)] + assert slot.reasoning_effort == "high" + assert state.sessions.effort_events == [ + ("hold", key), + ("set", key, False), + ("release", key), + ] + + @pytest.mark.asyncio + async def test_a_level_whose_cleared_default_cannot_be_saved_is_refused_unpushed(self): + provider = self._live(change_effort=AsyncMock(return_value=True)) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + assert await self._pick(state, "high") == 503 + provider.change_effort.assert_not_awaited() + assert slot.reasoning_effort == "" + assert state.sessions.default_intents == {key: True} + + @pytest.mark.asyncio + @pytest.mark.parametrize("push", ["refused", "raised"]) + async def test_a_level_push_that_does_not_end_on_the_level_puts_the_default_back( + self, monkeypatch, push + ): + # The push did not land and the fallback then declined, so the pick + # replaced nothing and the Default it cleared comes back. + monkeypatch.setattr(chat_handlers, "_switch_target_busy", lambda *_args: True) + change_effort = ( + AsyncMock(return_value=False) + if push == "refused" + else AsyncMock(side_effect=RuntimeError("rpc failed")) + ) + provider = self._live(change_effort=change_effort) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + + assert await self._pick(state, "high") == 409 + assert slot.reasoning_effort == "" + assert state.sessions.default_intents == {key: True} + assert state.sessions.effort_events == [ + ("hold", key), + ("set", key, False), + ("set", key, True), + ("release", key), + ] + + @pytest.mark.asyncio + async def test_a_put_back_that_cannot_be_saved_keeps_the_default_in_memory( + self, monkeypatch, caplog + ): + monkeypatch.setattr(chat_handlers, "_switch_target_busy", lambda *_args: True) + provider = self._live(change_effort=AsyncMock(return_value=False)) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + state.sessions.aflush = AsyncMock(side_effect=[None, OSError("disk full")]) + + assert await self._pick(state, "high") == 409 + # The map's next save writes it back to the file. + assert state.sessions.default_intents == {key: True} + assert "Could not save the explicit effort Default again" in caplog.text + + # -- a cold start in flight under the key owns its effort basis ------------- + # + # The start reads the one-shot Default once and publishes a session built on + # that read. A pick landing in between that would change the flag would + # leave the slot showing a value the session does not run, so the handler + # refuses it while the key's allocation reservation is held + # (``SessionManager.effort_basis_locked``); a level pick on a key whose flag + # is already clear changes no basis and lands. + + @staticmethod + async def _pick_response(state: DashboardState, effort: str) -> tuple[int, dict]: + async with TestClient(TestServer(_make_app(state))) as client: + resp = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": effort} + ) + return resp.status, await resp.json() + + @staticmethod + def _real_manager(monkeypatch, tmp_path): + """A real ``SessionManager``, so ``effort_basis_locked`` folds keys as in production.""" + from kiro_crew import session as session_module + + monkeypatch.setenv("KIROCREW_HOME", str(tmp_path / "kirocrew_home")) + cfg = MagicMock() + cfg.session.pool_size = 0 + cfg.session.pool_agent = "kirocrew" + cfg.session.pool_ttl_secs = 1800 + cfg.session.timeout_secs = 3600 + cfg.agent.default_agent = "" + cfg.agent.model = "auto" + with monkeypatch.context() as scoped: + scoped.setattr(session_module, "default_project_dir", lambda: str(tmp_path)) + return session_module.SessionManager(cfg, provider_factory=MagicMock()) + + @staticmethod + def _hold_reservation(manager, key: str, *, cold_start: bool = True) -> None: + # The token the allocation path adds before ``_get_or_create_impl`` and + # removes after registration; held here for the whole request. A cold + # start also marks it from before its basis read; a caller queued on + # the live session's lease does not. + service = manager._allocation_boundary() + token = object() + service._allocation_reservations.setdefault(key, set()).add(token) + if cold_start: + service.state.cold_start_reservations.add(token) + + @pytest.mark.asyncio + @pytest.mark.parametrize("effort", ["high", ""]) + @pytest.mark.parametrize("shape", ["no_session", "turn_in_flight"]) + async def test_a_pick_is_refused_while_the_keys_start_is_in_flight(self, effort, shape): + # A level and a Default alike: the request changes nothing and answers + # the retryable 409 the picker already has copy for. + provider = ( + self._live(has_active_turn=MagicMock(return_value=True)) + if shape == "turn_in_flight" + else None + ) + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = effort != "" + state.sessions.effort_basis_locked = MagicMock(side_effect=lambda k: k == key) + + status, body = await self._pick_response(state, effort) + + assert status == 409, body + assert body["code"] == "turn_in_flight" + assert slot.reasoning_effort == "low" + assert state.sessions.default_intents == {key: effort != ""} + state.sessions.set_explicit_effort_default.assert_not_called() + state.sessions.aflush.assert_not_awaited() + state.sessions.reset.assert_not_awaited() + if provider is not None: + # Nothing reached the live session or the overlay file. + provider.change_effort.assert_not_awaited() + provider.clear_effort.assert_not_awaited() + state.sessions.effort_basis_locked.assert_called_once_with(key) + + @pytest.mark.asyncio + async def test_a_pending_default_pick_lands_while_a_claim_waits_on_the_live_turn( + self, monkeypatch, tmp_path + ): + # A second claimant waits on the live session's lease for the whole turn + # with the key's reservation held. It starts nothing and reads no basis, + # so a Default pick is not refused as if a session were starting: it + # takes the deferred path the live turn gives any pick. + manager = self._real_manager(monkeypatch, tmp_path) + provider = self._live(has_active_turn=MagicMock(return_value=True)) + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + state.sessions.effort_basis_locked = manager.effort_basis_locked + self._hold_reservation(manager, key, cold_start=False) + assert manager.effort_basis_locked(key) is False + + status, body = await self._pick_response(state, "") + + assert status == 200, body + assert body == {"ok": True, "reasoning_effort": "", "deferred": True} + assert slot.reasoning_effort == "" + assert state.sessions.default_intents == {key: True} + provider.change_effort.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_level_pick_with_nothing_to_record_is_deferred_during_a_start( + self, monkeypatch, tmp_path + ): + # The level changes no flag, so the start in flight reads the same + # basis either way: the pick takes the deferred path instead of the + # start's 409, and writes nothing. + manager = self._real_manager(monkeypatch, tmp_path) + provider = self._live(has_active_turn=MagicMock(return_value=True)) + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.effort_basis_locked = manager.effort_basis_locked + self._hold_reservation(manager, key) + assert manager.effort_basis_locked(key) is True + + status, body = await self._pick_response(state, "high") + + assert status == 200, body + assert body == {"ok": True, "reasoning_effort": "high", "deferred": True} + assert slot.reasoning_effort == "high" + assert state.sessions.default_intents == {} + state.sessions.set_explicit_effort_default.assert_not_called() + state.sessions.effort_intent_write.assert_not_called() + state.sessions.aflush.assert_not_awaited() + provider.change_effort.assert_not_awaited() + provider.clear_effort.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("effort", "pending"), [("high", True), ("", False)], ids=["level", "default"] + ) + async def test_a_pick_that_would_change_the_flag_is_refused_during_a_live_turn( + self, monkeypatch, tmp_path, effort, pending + ): + # A start's reservation: a level that would clear a pending Default + # and a Default that would arm one both change the basis the guarded + # start reads, so each keeps the 409 and changes nothing. + manager = self._real_manager(monkeypatch, tmp_path) + provider = self._live(has_active_turn=MagicMock(return_value=True)) + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = pending + state.sessions.effort_basis_locked = manager.effort_basis_locked + self._hold_reservation(manager, key) + + status, body = await self._pick_response(state, effort) + + assert status == 409, body + assert body["code"] == "turn_in_flight" + assert slot.reasoning_effort == "low" + assert state.sessions.default_intents == {key: pending} + state.sessions.set_explicit_effort_default.assert_not_called() + state.sessions.effort_intent_write.assert_not_called() + state.sessions.aflush.assert_not_awaited() + provider.change_effort.assert_not_awaited() + provider.clear_effort.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_levels_clear_of_a_pending_default_is_refused_before_its_push(self): + # The level would clear the Default before pushing live; with the key's + # start in flight that clear is refused and the push never happens, so + # the overlay file and the live session stay as they were. + provider = self._live(change_effort=AsyncMock(return_value=True)) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + state.sessions.effort_basis_locked = MagicMock(return_value=True) + + status, body = await self._pick_response(state, "high") + + assert status == 409, body + assert body["code"] == "turn_in_flight" + assert slot.reasoning_effort == "" + assert state.sessions.default_intents == {key: True} + provider.change_effort.assert_not_awaited() + assert state.sessions.effort_events == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize("effort", ["high", ""]) + async def test_the_retry_lands_once_the_start_has_registered(self, effort): + # Each pick would change the flag (the level clears a pending Default, + # the Default arms one), so the start in flight refuses it. + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = effort != "" + in_flight = True + state.sessions.effort_basis_locked = MagicMock(side_effect=lambda _k: in_flight) + + assert await self._pick(state, effort) == 409 + assert slot.reasoning_effort == "low" + assert state.sessions.default_intents == {key: effort != ""} + + in_flight = False + + assert await self._pick(state, effort) == 200 + assert slot.reasoning_effort == effort + # What the key's next cold start reads is the pick that landed. + assert state.sessions.default_intents == {key: effort == ""} + + @pytest.mark.asyncio + async def test_a_start_under_another_key_does_not_refuse_the_pick(self, monkeypatch, tmp_path): + manager = self._real_manager(monkeypatch, tmp_path) + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + state.sessions.effort_basis_locked = manager.effort_basis_locked + self._hold_reservation(manager, "dashboard:another") + + assert await self._pick(state, "") == 200 + assert slot.reasoning_effort == "" + assert state.sessions.default_intents == {key: True} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("slot_key", "reserved_key"), + [ + ("slack:1700000000.000100", "1700000000.000100"), + ("1700000000.000100", "slack:1700000000.000100"), + ], + ) + async def test_a_start_under_a_folded_alias_of_the_key_refuses_the_pick( + self, monkeypatch, tmp_path, slot_key, reserved_key + ): + # The legacy bare thread_ts and its ``slack:`` spelling name one session, + # so a start reserved under either spelling owns the basis of both. + manager = self._real_manager(monkeypatch, tmp_path) + slot = _ChatSlot("test") + slot.reasoning_effort = "low" + slot.linked_session_key = slot_key + state = _mock_state(slot) + assert chat_handlers.effective_session_key(slot) == slot_key + state.sessions.effort_basis_locked = manager.effort_basis_locked + self._hold_reservation(manager, reserved_key) + + status, body = await self._pick_response(state, "") + + assert status == 409, body + assert body["code"] == "turn_in_flight" + assert slot.reasoning_effort == "low" + assert state.sessions.default_intents == {} + + @pytest.mark.asyncio + async def test_the_put_back_still_arms_the_default_while_a_start_is_in_flight( + self, monkeypatch + ): + # The level cleared the Default before a start began under the key, and + # its push then did not land. The put-back restores a Default this same + # request cleared: a start that read the cleared value arms nothing, and + # the NEXT cold start applies the flag, so the write is not refused. + monkeypatch.setattr(chat_handlers, "_switch_target_busy", lambda *_args: True) + locked: list[bool] = [] + + async def change_effort(_level: str) -> bool: + # The start begins once the clear is saved and the push is in flight. + locked.append(True) + return False + + provider = self._live(change_effort=AsyncMock(side_effect=change_effort)) + slot = _ChatSlot("test") + slot.reasoning_effort = "" + state = _mock_state(slot, provider) + key = chat_handlers.effective_session_key(slot) + state.sessions.default_intents[key] = True + state.sessions.effort_basis_locked = MagicMock(side_effect=lambda _k: bool(locked)) + + assert await self._pick(state, "high") == 409 + assert slot.reasoning_effort == "" + assert state.sessions.default_intents == {key: True} + assert state.sessions.effort_events == [ + ("hold", key), + ("set", key, False), + ("set", key, True), + ("release", key), + ] + # The clear was checked; the put-back is exempt and was not. + state.sessions.effort_basis_locked.assert_called_once_with(key) + + # -- a pick saving its flag when a start begins is settled before the read -- + # + # The check passes before the start takes the key's reservation, so the + # pick's in-memory flag is already written when the start begins and its + # save is still in flight. The handler counts the write from that flag + # through the save and its put-back (``effort_intent_write``), and the start + # waits for the count to settle before its basis read. + + async def _a_default_pick_saving_as_a_start_begins( + self, manager, state: DashboardState, key: str, outcome: str + ) -> tuple[int | None, list[tuple[bool, bool]]]: + """Race a Default pick's save against a cold start that begins during it. + + The start is modelled as the allocation makes it: it takes the key's + reservation while the save is in flight, waits for the key's intent + writes, then reads the flag once. ``outcome`` is what the pick's save + does once the start is waiting: "saved", "raised" or "cancelled". + Returns the pick's status (None when the connection dropped) and the + start's read as ``(value, save_settled)``. + """ + import asyncio + + import aiohttp + + save_started = asyncio.Event() + save_release = asyncio.Event() + start_waiting = asyncio.Event() + save_settled = False + flushes = 0 + reads: list[tuple[bool, bool]] = [] + + async def aflush() -> None: + nonlocal flushes, save_settled + flushes += 1 + if flushes > 1: + # The put-back's save after a cancelled one. + return + save_started.set() + await _await_test(save_release.wait(), "save_release") + save_settled = True + if outcome == "raised": + raise OSError("disk full") + if outcome == "cancelled": + raise asyncio.CancelledError + + async def cold_start() -> None: + await _await_test(save_started.wait(), "save_started") + self._hold_reservation(manager, key) + start_waiting.set() + await manager.wait_for_effort_intent_writes(key) + reads.append((state.sessions.default_intents.get(key, False), save_settled)) + + state.sessions.aflush = AsyncMock(side_effect=aflush) + state.sessions.effort_basis_locked = manager.effort_basis_locked + state.sessions.effort_intent_write = manager.effort_intent_write + start = asyncio.create_task(cold_start()) + request: asyncio.Task | None = None + try: + async with TestClient(TestServer(_make_app(state))) as client: + request = asyncio.create_task( + client.post( + "/api/chat/slots/test/reasoning-effort", + json={"reasoning_effort": ""}, + ) + ) + await _await_test(start_waiting.wait(), "cold start reaching its basis read") + save_release.set() + try: + resp = await _await_test(request, "the Default pick request") + except aiohttp.ClientError: + resp = None + await _await_test(start, "cold start completion") + finally: + save_release.set() + participants = [start] + if request is not None: + participants.append(request) + for participant in participants: + if not participant.done(): + participant.cancel() + await _await_test( + asyncio.gather(*participants, return_exceptions=True), + "readiness-race cleanup", + ) + return (None if resp is None else resp.status), reads + + @pytest.mark.asyncio + async def test_a_default_whose_save_raises_under_a_start_is_read_as_put_back( + self, monkeypatch, tmp_path + ): + manager = self._real_manager(monkeypatch, tmp_path) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + + status, reads = await self._a_default_pick_saving_as_a_start_begins( + manager, state, key, "raised" + ) + + # The pick is refused and put the prior value back; the start read that + # value, after the save had settled, so it arms nothing. + assert status == 503 + assert reads == [(False, True)] + assert state.sessions.default_intents == {key: False} + assert slot.reasoning_effort == "high" + assert key not in manager._effort_intent_writes + + @pytest.mark.asyncio + async def test_a_default_whose_save_lands_under_a_start_is_read_once_saved( + self, monkeypatch, tmp_path + ): + manager = self._real_manager(monkeypatch, tmp_path) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + + status, reads = await self._a_default_pick_saving_as_a_start_begins( + manager, state, key, "saved" + ) + + # The start read the pick's value only once its save had settled. + assert status == 200 + assert reads == [(True, True)] + assert state.sessions.default_intents == {key: True} + assert slot.reasoning_effort == "" + assert key not in manager._effort_intent_writes + + @pytest.mark.asyncio + async def test_a_default_whose_save_is_cancelled_under_a_start_is_read_as_put_back( + self, monkeypatch, tmp_path + ): + manager = self._real_manager(monkeypatch, tmp_path) + slot = _ChatSlot("test") + slot.reasoning_effort = "high" + state = _mock_state(slot) + key = chat_handlers.effective_session_key(slot) + + status, reads = await self._a_default_pick_saving_as_a_start_begins( + manager, state, key, "cancelled" + ) + + # The cancelled pick committed nothing and put the prior value back + # before its write exited; that is what the start read. + assert status is None or status >= 500 + assert reads == [(False, True)] + assert state.sessions.default_intents == {key: False} + assert slot.reasoning_effort == "high" + state.sessions.reset.assert_not_called() + assert key not in manager._effort_intent_writes + + +class TestEffortIntentFailureMatrix: + """No answer reports success after a failed intent write. + + Asserted over every exit family rather than per path: a 2xx needs every + intent write the request made to land, and whatever the answer, the slot + shows the level the live session was last left on or, with nothing pushed, + the old value or the pick a later start applies. + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("before", "requested", "live", "rebind", "undo_lands", "failing_flush"), + [ + pytest.param("", "high", "idle", False, None, None, id="live-level-saved"), + pytest.param("", "high", "idle", False, None, "every", id="live-level-unsaved"), + pytest.param("high", "", "idle", False, None, "every", id="live-default-unsaved"), + pytest.param("", "high", "idle", True, None, "second", id="rebound-old-unsaved"), + pytest.param("high", "", "idle", True, True, "every", id="rebound-new-unsaved-undone"), + pytest.param("high", "", "idle", True, False, "every", id="rebound-new-unsaved-kept"), + pytest.param("", "high", "busy", False, None, "every", id="deferred-unsaved"), + pytest.param("high", "", "stopped", False, None, "every", id="stopped-unsaved"), + pytest.param( + "high", "", "no-effort", False, None, "every", id="model-without-effort-unsaved" + ), + ], + ) + async def test_the_answer_never_publishes_an_unsaved_intent( + self, before, requested, live, rebind, undo_lands, failing_flush + ): + slot = _ChatSlot("test") + slot.reasoning_effort = before + running: list[str] = [] + + async def push(level: str) -> bool: + if running and undo_lands is not None: + # A second push is the undo of the first. + if undo_lands: + running.append(level) + return undo_lands + running.append(level) + if rebind: + slot.linked_session_key = "slack:1700000000.000100" + return True + + async def change_effort(level: str) -> bool: + return await push(level) + + async def clear_effort(**_kwargs: object) -> bool: + return await push("") + + provider = None + if live != "stopped": + provider = TestExplicitDefaultIntent._live( + change_effort=AsyncMock(side_effect=change_effort), + clear_effort=AsyncMock(side_effect=clear_effort), + supports_effort=MagicMock(return_value=live != "no-effort"), + has_active_turn=MagicMock(return_value=live == "busy"), + ) + state = _mock_state(slot, provider) + flushes = 0 + failed_flushes = 0 + + async def flush() -> None: + nonlocal flushes, failed_flushes + flushes += 1 + if failing_flush == "every" or (failing_flush == "second" and flushes == 2): + failed_flushes += 1 + raise OSError("disk full") + + state.sessions.aflush = AsyncMock(side_effect=flush) + + async with TestClient(TestServer(_make_app(state))) as client: + response = await client.post( + "/api/chat/slots/test/reasoning-effort", json={"reasoning_effort": requested} + ) + body = await response.json() + + if failed_flushes: + assert response.status == 503, body + if response.status < 300: + assert slot.reasoning_effort == requested + if running: + assert slot.reasoning_effort == running[-1] + else: + assert slot.reasoning_effort in (before, requested) + if body.get("code") == "effort_intent_unavailable": + assert slot.reasoning_effort == before + if body.get("code") == "effort_intent_unsaved": + assert body["reasoning_effort"] == slot.reasoning_effort + assert slot._effort_intent_owed is True + + +class TestEffortIntentRetry: + @pytest.mark.asyncio + async def test_the_same_pick_retries_an_owed_intent_write(self): + slot = _ChatSlot("test") + provider = TestExplicitDefaultIntent._live(change_effort=AsyncMock(return_value=True)) + state = _mock_state(slot, provider) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + assert await TestExplicitDefaultIntent._pick(state, "high") == 503 + assert slot._effort_intent_owed is True + state.sessions.aflush = AsyncMock() + assert await TestExplicitDefaultIntent._pick(state, "high") == 200 + assert slot._effort_intent_owed is False + assert await TestExplicitDefaultIntent._pick(state, "high") == 200 + assert provider.change_effort.await_count == 2 + + @pytest.mark.asyncio + async def test_the_same_pick_after_a_saved_intent_is_a_no_op(self): + slot = _ChatSlot("test") + provider = TestExplicitDefaultIntent._live(change_effort=AsyncMock(return_value=True)) + state = _mock_state(slot, provider) + + assert await TestExplicitDefaultIntent._pick(state, "high") == 200 + assert slot._effort_intent_owed is False + assert await TestExplicitDefaultIntent._pick(state, "high") == 200 + provider.change_effort.assert_awaited_once_with("high") + + @pytest.mark.asyncio + async def test_a_refused_intent_write_marks_the_slot_owed(self): + slot = _ChatSlot("test") + provider = TestExplicitDefaultIntent._live(change_effort=AsyncMock(return_value=True)) + state = _mock_state(slot, provider) + state.sessions.set_explicit_effort_default = MagicMock(return_value=False) + + assert await TestExplicitDefaultIntent._pick(state, "high") == 503 + assert slot._effort_intent_owed is True + + @pytest.mark.asyncio + async def test_owed_flag_does_not_survive_slot_persistence_round_trip(self, tmp_path): + from chat_test_helpers import _make_state + + state = _make_state(tmp_path) + slot = state.get_or_create_slot("test") + slot._effort_intent_owed = True + slot.append("user", "persist me") + assert await chat_persistence.save_slot_off_loop(state, slot, force=True) + + restored_state = _make_state(tmp_path) + restored = chat_persistence._rehydrate_slot_from_history(restored_state, "test") + assert restored is not None + assert restored._effort_intent_owed is False diff --git a/test/test_dashboard_chat_handlers_coverage.py b/test/test_dashboard_chat_handlers_coverage.py index a40e9a94859..d9fcd1575c3 100644 --- a/test/test_dashboard_chat_handlers_coverage.py +++ b/test/test_dashboard_chat_handlers_coverage.py @@ -15,6 +15,7 @@ from __future__ import annotations +import contextlib import json from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -59,6 +60,13 @@ def _state(slot: _ChatSlot | None = None) -> DashboardState: state.broadcast_ws = MagicMock() state.broadcast_context_usage = MagicMock() state.sessions = MagicMock() + state.sessions.aflush = AsyncMock() + state.sessions.explicit_effort_default_pending = MagicMock(return_value=False) + state.sessions.set_explicit_effort_default = MagicMock(return_value=True) + state.sessions.effort_basis_locked = MagicMock(return_value=False) + state.sessions.effort_intent_write = MagicMock( + side_effect=lambda _key: contextlib.nullcontext() + ) 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 4c74f703934..44562579e6b 100644 --- a/test/test_eager_spawn.py +++ b/test/test_eager_spawn.py @@ -33,8 +33,18 @@ from kiro_crew.dashboard.chat_runner import _eager_spawn, schedule_eager_spawn from kiro_crew.dashboard.state import DashboardState, _ChatSlot from kiro_crew.session import FirstTurnState -from kiro_crew.start_priority import StartPriority -from kiro_crew.testing.wait import until_parked +from kiro_crew.start_priority import START_QUEUE_COLD_START, StartPriority +from kiro_crew.testing.wait import default_timeout, until_parked + + +async def _await_test(awaitable, what: str): + try: + async with asyncio.timeout(default_timeout()) as deadline: + return await awaitable + except TimeoutError as exc: + if not deadline.expired(): + raise + raise AssertionError(f"timed out waiting for {what}") from exc def _cfg_with_store(name: str, *, version: int, owner: str = "") -> KiroCrewConfig: @@ -451,6 +461,140 @@ async def _create_then_switch(*a, **kw): # Semaphore still released before teardown. state.sessions.release.assert_called_once_with(key) + @pytest.mark.asyncio + async def test_default_pick_cannot_overtake_prefetch_effort_capture(self): + """A newer Default intent cannot be spent against a stale eager override.""" + slot = _ChatSlot("effort-race") + slot.reasoning_effort = "max" + state = _mock_state(slot) + allocation_entered = asyncio.Event() + release_allocation = asyncio.Event() + picker_started = asyncio.Event() + captured_overrides: list[str | None] = [] + default_pending = False + stale_override_spent = False + + async def allocate(*_args, **kwargs): + nonlocal default_pending, stale_override_spent + captured_override = kwargs.get("reasoning_effort_override") + captured_overrides.append(captured_override) + allocation_entered.set() + await _await_test(release_allocation.wait(), "release_allocation") + if default_pending and captured_override: + default_pending = False + stale_override_spent = True + return MagicMock(), True, False + + async def pick_default() -> None: + nonlocal default_pending + picker_started.set() + async with chat_runner.slot_switch_session_lock( + chat_runner.effective_session_key(slot) + ): + slot.reasoning_effort = "" + default_pending = True + + state.sessions.get_or_create = AsyncMock(side_effect=allocate) + picker_task: asyncio.Task[None] | None = None + prefetch_task = asyncio.create_task(_eager_spawn(state, slot)) + try: + await _await_test(allocation_entered.wait(), "the prefetch entering allocation") + picker_task = asyncio.create_task(pick_default()) + await _await_test(picker_started.wait(), "the Default pick starting") + release_allocation.set() + await _await_test(prefetch_task, "the prefetch completion") + await _await_test(picker_task, "the Default pick completion") + finally: + release_allocation.set() + tasks = [prefetch_task] + if picker_task is not None: + tasks.append(picker_task) + for task in tasks: + if not task.done(): + task.cancel() + await _await_test(asyncio.gather(*tasks, return_exceptions=True), "task cleanup") + + assert captured_overrides == ["max"] + assert stale_override_spent is False + assert default_pending is True + + @pytest.mark.asyncio + async def test_a_prefetch_queued_in_the_start_gate_does_not_hold_the_switch_lock(self): + """A person's first turn takes the slot's switch lock before it allocates + (``_run_chat``'s dispatch lock). A prefetch waiting for a BACKGROUND + cold-start permit must have handed that lock off at the gate's entry, or + the turn waits out the prefetch's whole queued start instead of running + its own FOREGROUND start beside it.""" + slot = _ChatSlot("gate-queued") + slot.reasoning_effort = "max" + state = _mock_state(slot) + queued_at_gate = asyncio.Event() + permit_granted = asyncio.Event() + lock = chat_runner.slot_switch_session_lock(chat_runner.effective_session_key(slot)) + lock_free_while_queued: bool | None = None + + async def allocate(*_args, **kwargs): + # ``get_or_create`` at its cold-start gate: the key's reservation is + # already held, the queue-entry hook fires, then the permit wait. + on_gate_queued = kwargs.get("on_gate_queued") + if on_gate_queued is not None: + on_gate_queued(START_QUEUE_COLD_START) + queued_at_gate.set() + await _await_test(permit_granted.wait(), "a background cold-start permit") + return MagicMock(), True, False + + state.sessions.get_or_create = AsyncMock(side_effect=allocate) + prefetch_task = asyncio.create_task(_eager_spawn(state, slot)) + try: + await _await_test(queued_at_gate.wait(), "the prefetch reaching the start gate") + # The turn's dispatch-lock acquire, while the prefetch is still queued. + await _await_test(lock.acquire(), "the turn's dispatch lock") + lock_free_while_queued = not prefetch_task.done() + lock.release() + finally: + permit_granted.set() + if not prefetch_task.done(): + await _await_test( + asyncio.gather(prefetch_task, return_exceptions=True), "prefetch cleanup" + ) + + assert lock_free_while_queued is True + assert state.sessions.get_or_create.await_count == 1 + + @pytest.mark.asyncio + async def test_the_switch_lock_is_released_once_when_the_gate_is_never_reached(self): + """An allocation that never fires the gate hook (a warm-pool claim, a + refusal ahead of the gate) releases the lock on exit; one that fired it + does not release it a second time. Observations are recorded, not + asserted, inside the allocation: an assertion raised there is a start + failure ``_eager_spawn`` logs and swallows.""" + locked_inside: list[bool] = [] + + async def allocate_without_gate(*_args, **_kwargs): + locked_inside.append(lock.locked()) + return MagicMock(), True, False + + async def allocate_through_gate(*_args, **kwargs): + kwargs["on_gate_queued"](START_QUEUE_COLD_START) + locked_inside.append(lock.locked()) + return MagicMock(), True, False + + for name, allocate in ( + ("gate-skipped", allocate_without_gate), + ("gate-passed", allocate_through_gate), + ): + slot = _ChatSlot(name) + state = _mock_state(slot) + lock = chat_runner.slot_switch_session_lock(chat_runner.effective_session_key(slot)) + state.sessions.get_or_create = AsyncMock(side_effect=allocate) + await _await_test(_eager_spawn(state, slot), f"the {name} prefetch") + assert state.sessions.get_or_create.await_count == 1 + assert not lock.locked() + + # The effort read and the reservation ran under the lock; the gate's + # entry handed it off. + assert locked_inside == [True, False] + @pytest.mark.asyncio async def test_unchanged_bindings_keep_the_session(self, tmp_path): """The bindings guard must not tear down the common case.""" @@ -564,6 +708,31 @@ async def test_speculative_claim_does_not_consume(self, cfg): mgr.release(key) assert mgr._sessions[key].first_turn is FirstTurnState.FRESH + @pytest.mark.asyncio + async def test_the_cold_start_gate_hook_fires_with_the_effort_basis_reserved(self, cfg): + """The handoff the eager prefetch makes at ``on_gate_queued`` rests on + this ordering: when the hook fires, the key's allocation reservation is + already held, so an effort pick that would change the basis is refused + (``effort_basis_locked``) from that point until registration.""" + from kiro_crew.session import SessionManager + + mgr = SessionManager(cfg, provider_factory=_stub_factory()) + key = "dashboard:eager-gate" + basis_locked_at_gate: list[bool] = [] + try: + assert mgr.effort_basis_locked(key) is False + await mgr.get_or_create( + key, + speculative=True, + on_gate_queued=lambda queue: basis_locked_at_gate.append( + mgr.effort_basis_locked(key) + ), + ) + mgr.release(key) + finally: + await mgr.close_all() + assert basis_locked_at_gate == [True] + @pytest.mark.asyncio async def test_speculative_refuses_resumable_key(self, cfg, tmp_path, monkeypatch): """A key with a session-map entry raises instead of resuming, on the diff --git a/test/test_effort.py b/test/test_effort.py index 908d04e6be7..9ba71a52375 100644 --- a/test/test_effort.py +++ b/test/test_effort.py @@ -3,14 +3,24 @@ from __future__ import annotations +import asyncio +import copy import json import logging +import os +import sys +import tempfile +import unittest +import unittest.mock from contextlib import contextmanager from pathlib import Path from unittest.mock import MagicMock, patch import pytest +from effort_helpers import _UNPARSEABLE_OVERLAY_CONTENTS, _read_cli_overlay +from kiro_crew.acp.types import ACP_BACKEND_KIRO +from kiro_crew.apps.builtins.code_review_sage.sage_lib import review_pool as rp from kiro_crew.config.loader import KiroCrewConfig from kiro_crew.effort import ( EFFORT_LEVELS, @@ -22,10 +32,47 @@ ) from kiro_crew.providers import acp as acp_provider from kiro_crew.providers.acp import ( + _KIROCREW_EFFORT_OWNED_KEY, _clear_cli_overlay_effort, - _read_cli_overlay, _write_cli_overlay, ) +from kiro_crew.testing.wait import default_timeout + + +async def _await_test(awaitable, what: str): + try: + async with asyncio.timeout(default_timeout()) as deadline: + return await awaitable + except TimeoutError as exc: + if not deadline.expired(): + raise + raise AssertionError(f"timed out waiting for {what}") from exc + + +@pytest.mark.asyncio +async def test_await_test_propagates_awaited_timeout(): + async def raise_timeout(): + raise TimeoutError("awaited operation timed out") + + with pytest.raises(TimeoutError, match="awaited operation timed out"): + await _await_test(raise_timeout(), "inner timeout") + + +@pytest.mark.asyncio +async def test_await_test_reports_own_deadline_and_cancels_awaitable(monkeypatch): + cancelled = asyncio.Event() + + async def never_finishes(): + try: + await asyncio.Event().wait() + finally: + cancelled.set() + + monkeypatch.setattr(sys.modules[__name__], "default_timeout", lambda: 0) + + with pytest.raises(AssertionError, match="^timed out waiting for blocked test$"): + await _await_test(never_finishes(), "blocked test") + assert cancelled.is_set() class TestEffortVocabulary: @@ -201,6 +248,463 @@ def test_read_malformed_returns_empty(self, tmp_path): (settings_dir / "cli.json").write_text("{ not json") assert _read_cli_overlay(tmp_path) == {} + def test_read_undecodable_returns_empty(self, tmp_path): + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + (settings_dir / "cli.json").write_bytes(b'{"x": "\xff"}') + assert _read_cli_overlay(tmp_path) == {} + + @pytest.mark.parametrize( + "contents", + [ + b"{ not json", + b'{"x": "\xff"}', + b"[1, 2]", + b'{"chat.modelDefaults": {"m": {"output_config": {"effort": "max"}}}, "n": ' + + b"1" * 5000 + + b"}", + b"[" * 200_000 + b"]" * 200_000, + ], + ids=["malformed", "undecodable", "non-object", "oversized-int-literal", "deeply-nested"], + ) + def test_write_onto_an_unmergeable_file_raises_and_leaves_it_unchanged( + self, tmp_path, contents + ): + # A silent return here would let the caller report a persisted level the + # next respawn drops, and a reset to {} would destroy operator data. The + # file is neither. + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_bytes(contents) + + with pytest.raises(ValueError, match="left unchanged"): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + + assert cli_json.read_bytes() == contents + assert _read_cli_overlay(tmp_path) == {} + + def test_write_onto_an_unreadable_file_raises_the_read_error(self, tmp_path, monkeypatch): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + real_read = acp_provider.safe_read_file_bytes_nolink + + def _flaky_read(raw, *args, **kwargs): + if Path(raw).name == "cli.json": + raise OSError("sharing violation") + return real_read(raw, *args, **kwargs) + + def _failed_pinned_read(_settings_fd, _cli_json): + raise OSError("sharing violation") + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "safe_read_file_bytes_nolink", _flaky_read) + temporary_patches.setattr(acp_provider, "_read_pinned_cli_json", _failed_pinned_read) + with pytest.raises(OSError, match="sharing violation"): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "low") + + assert cli_json.read_bytes() == before + + def test_projection_publishes_value_and_record_in_one_write(self, tmp_path, monkeypatch): + model = "claude-opus-4.7" + _write_cli_overlay(tmp_path, model, "max") + writes = [] + real_atomic_write = acp_provider.atomic_write + + def _record_write(target, content, **kwargs): + writes.append(target) + real_atomic_write(target, content, **kwargs) + + monkeypatch.setattr(acp_provider, "atomic_write", _record_write) + _write_cli_overlay(tmp_path, model, "low") + + assert [target.name for target in writes] == ["cli.json"] + data = json.loads(writes[0].read_text(encoding="utf-8")) + assert data["chat.modelDefaults"][model]["output_config"]["effort"] == "low" + assert data[_KIROCREW_EFFORT_OWNED_KEY] == {model: "low"} + + def test_failed_projection_write_leaves_value_and_record_agreeing(self, tmp_path, monkeypatch): + model = "claude-opus-4.7" + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + writes = [] + + def _fail_write(target, content, **kwargs): + writes.append(target) + raise OSError("disk full") + + monkeypatch.setattr(acp_provider, "atomic_write", _fail_write) + with pytest.raises(OSError, match="disk full"): + _write_cli_overlay(tmp_path, model, "low") + + assert [target.name for target in writes] == ["cli.json"] + assert cli_json.read_bytes() == before + data = json.loads(before) + assert data["chat.modelDefaults"][model]["output_config"]["effort"] == "max" + assert data[_KIROCREW_EFFORT_OWNED_KEY] == {model: "max"} + + def test_own_write_stamp_keeps_owned_clear_valid(self, tmp_path): + model = "claude-opus-4.7" + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + + assert data["kirocrew.effortOwnedStamp"] == int(cli_json.stat().st_mtime) + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {} + + @staticmethod + def _stamp_clock(monkeypatch): + """Drive the stamp clock by hand, so a repeat write would take a different stamp.""" + from types import SimpleNamespace + + from kiro_crew import workspace_cli_settings + + clock = [1_790_000_000.0] + monkeypatch.setattr(workspace_cli_settings, "time", SimpleNamespace(time=lambda: clock[0])) + + def advance(seconds): + clock[0] += seconds + + return advance + + def test_same_level_write_leaves_cli_json_bytes_and_mtime_unchanged( + self, tmp_path, monkeypatch + ): + model = "claude-opus-4.7" + advance = self._stamp_clock(monkeypatch) + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + before_stat = cli_json.stat() + advance(10) + + _write_cli_overlay(tmp_path, model, "max") + + after_stat = cli_json.stat() + assert cli_json.read_bytes() == before + assert (after_stat.st_ino, after_stat.st_mtime_ns) == ( + before_stat.st_ino, + before_stat.st_mtime_ns, + ) + data = json.loads(before) + assert data["kirocrew.effortOwnedStamp"] == int(after_stat.st_mtime) + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {} + + def test_level_change_writes_and_restamps(self, tmp_path, monkeypatch): + model = "claude-opus-4.7" + advance = self._stamp_clock(monkeypatch) + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = json.loads(cli_json.read_text(encoding="utf-8")) + advance(10) + + _write_cli_overlay(tmp_path, model, "low") + + after = json.loads(cli_json.read_text(encoding="utf-8")) + assert after["chat.modelDefaults"][model]["output_config"]["effort"] == "low" + assert after[_KIROCREW_EFFORT_OWNED_KEY] == {model: "low"} + assert after["kirocrew.effortOwnedStamp"] == int(cli_json.stat().st_mtime) + assert after["kirocrew.effortOwnedStamp"] == before["kirocrew.effortOwnedStamp"] + 10 + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {} + + def test_same_level_write_after_outside_resave_drops_the_void_record( + self, tmp_path, monkeypatch + ): + model = "claude-opus-4.7" + advance = self._stamp_clock(monkeypatch) + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + resaved = cli_json.read_bytes() + cli_json.write_bytes(resaved) + assert json.loads(resaved)["kirocrew.effortOwnedStamp"] != int(cli_json.stat().st_mtime) + advance(10) + + _write_cli_overlay(tmp_path, model, "max") + + after = json.loads(cli_json.read_text(encoding="utf-8")) + assert cli_json.read_bytes() != resaved + assert after["chat.modelDefaults"][model]["output_config"]["effort"] == "max" + assert _KIROCREW_EFFORT_OWNED_KEY not in after + assert "kirocrew.effortOwnedStamp" not in after + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {model: "max"} + + def test_same_value_operator_rewrite_voids_owned_clear(self, tmp_path, caplog): + model = "claude-opus-4.7" + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + data["chat.modelDefaults"][model]["output_config"]["effort"] = "max" + cli_json.write_text(json.dumps(data), encoding="utf-8") + assert data["kirocrew.effortOwnedStamp"] != int(cli_json.stat().st_mtime) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert ( + _clear_cli_overlay_effort(tmp_path, model, owned_only=True, warn_unowned=True) + is True + ) + assert _read_cli_overlay(tmp_path) == {model: "max"} + assert any( + "holds an effort level Kiro Crew did not write" in record.getMessage() + for record in caplog.records + ) + after = json.loads(cli_json.read_text(encoding="utf-8")) + assert _KIROCREW_EFFORT_OWNED_KEY not in after + + def test_unrelated_tool_search_write_drops_void_effort_ownership(self, tmp_path): + model = "claude-opus-4.7" + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + data["chat.modelDefaults"][model]["output_config"]["effort"] = "max" + cli_json.write_text(json.dumps(data), encoding="utf-8") + + acp_provider._write_tool_search_overlay(tmp_path, True) + + after_write = json.loads(cli_json.read_text(encoding="utf-8")) + assert _KIROCREW_EFFORT_OWNED_KEY not in after_write + assert _clear_cli_overlay_effort(tmp_path, None, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {model: "max"} + + def test_tool_search_write_preserves_valid_effort_ownership(self, tmp_path): + model = "claude-opus-4.7" + _write_cli_overlay(tmp_path, model, "max") + + acp_provider._write_tool_search_overlay(tmp_path, True) + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data["kirocrew.effortOwnedStamp"] == int(cli_json.stat().st_mtime) + + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {} + + def test_operator_save_after_publish_voids_owned_clear(self, tmp_path, monkeypatch): + model = "claude-opus-4.7" + real_atomic_write = acp_provider.atomic_write + operator_saves = [] + + def _publish_then_operator_save(target, content, **kwargs): + real_atomic_write(target, content, **kwargs) + Path(target).write_bytes(content) + operator_saves.append(Path(target)) + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "atomic_write", _publish_then_operator_save) + _write_cli_overlay(tmp_path, model, "max") + + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + written = json.loads(cli_json.read_text(encoding="utf-8")) + assert operator_saves == [cli_json] + assert written["kirocrew.effortOwnedStamp"] != int(cli_json.stat().st_mtime) + + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {model: "max"} + after = json.loads(cli_json.read_text(encoding="utf-8")) + assert _KIROCREW_EFFORT_OWNED_KEY not in after + + @pytest.mark.parametrize( + "stored_mtime_quantum_ns", + [ + pytest.param(1, id="nanosecond"), + pytest.param(2_000_000_000, id="fat-two-second"), + ], + ) + def test_operator_save_one_clock_tick_behind_the_publish_voids_owned_clear( + self, tmp_path, monkeypatch, stored_mtime_quantum_ns + ): + from types import SimpleNamespace + + from kiro_crew import workspace_cli_settings + + model = "claude-opus-4.7" + publish_ns = 1_790_000_000_002_000_000 + monkeypatch.setattr( + workspace_cli_settings, "time", SimpleNamespace(time=lambda: publish_ns / 1e9) + ) + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + published = json.loads(cli_json.read_text(encoding="utf-8")) + if stored_mtime_quantum_ns == 2_000_000_000: + assert published["kirocrew.effortOwnedStamp"] % 2 == 0 + cli_json.write_text(cli_json.read_text(encoding="utf-8"), encoding="utf-8") + # The kernel dates a save from a clock that can trail time.time() by one tick + # (about 16 ms on Windows), so a save just after a second boundary can be + # stored behind the publish clock. + lagging_save_ns = publish_ns - 16_000_000 + stored_lagging_save_ns = ( + lagging_save_ns // stored_mtime_quantum_ns * stored_mtime_quantum_ns + ) + os.utime(cli_json, ns=(stored_lagging_save_ns, stored_lagging_save_ns)) + + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + assert _read_cli_overlay(tmp_path) == {model: "max"} + + def test_recursive_document_clear_fails_closed_without_touching_file(self, tmp_path): + model = "claude-opus-4.7" + depth = sys.getrecursionlimit() * 3 // 5 + raw = ( + b'{"chat.modelDefaults":{"claude-opus-4.7":{"output_config":{"effort":"max"}}},' + b'"deep":' + b"[" * depth + b"0" + b"]" * depth + b"}" + ) + parsed = json.loads(raw) + assert isinstance(parsed, dict), "the JSON reader must accept the recursive fixture" + with pytest.raises(RecursionError): + copy.deepcopy(parsed) + + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_bytes(raw) + before = cli_json.read_bytes() + before_mtime = cli_json.stat().st_mtime_ns + + assert _clear_cli_overlay_effort(tmp_path, model) is False + assert _clear_cli_overlay_effort(tmp_path, None) is False + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + assert provider._apply_effort_overlay() is False + assert cli_json.read_bytes() == before + assert cli_json.stat().st_mtime_ns == before_mtime + + def test_failed_clear_write_leaves_value_and_record_agreeing(self, tmp_path, monkeypatch): + model = "claude-opus-4.7" + _write_cli_overlay(tmp_path, model, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + writes = [] + + def _fail_write(target, content, **kwargs): + writes.append(target) + raise OSError("disk full") + + monkeypatch.setattr(acp_provider, "atomic_write", _fail_write) + assert _clear_cli_overlay_effort(tmp_path, model) is False + + assert [target.name for target in writes] == ["cli.json"] + assert cli_json.read_bytes() == before + data = json.loads(before) + assert data["chat.modelDefaults"][model]["output_config"]["effort"] == "max" + assert data[_KIROCREW_EFFORT_OWNED_KEY] == {model: "max"} + + def test_same_operator_value_is_not_claimed_by_projection(self, tmp_path): + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_text( + json.dumps( + {"chat.modelDefaults": {"claude-opus-4.7": {"output_config": {"effort": "max"}}}} + ), + encoding="utf-8", + ) + + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + assert provider._apply_effort_overlay() is True + + assert _read_cli_overlay(tmp_path) == {"claude-opus-4.7": "max"} + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert _KIROCREW_EFFORT_OWNED_KEY not in data + + def test_projection_preserves_unowned_null_effort_byte_for_byte(self, tmp_path): + model = "claude-opus-4.7" + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_text( + json.dumps({"chat.modelDefaults": {model: {"output_config": {"effort": None}}}}), + encoding="utf-8", + ) + before = cli_json.read_bytes() + + _write_cli_overlay(tmp_path, model, "max") + + assert cli_json.read_bytes() == before + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data["chat.modelDefaults"][model]["output_config"]["effort"] is None + assert _KIROCREW_EFFORT_OWNED_KEY not in data + + def test_owned_clear_preserves_unowned_null_effort_byte_for_byte(self, tmp_path): + model = "claude-opus-4.7" + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_text( + json.dumps({"chat.modelDefaults": {model: {"output_config": {"effort": None}}}}), + encoding="utf-8", + ) + before = cli_json.read_bytes() + + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + + assert cli_json.read_bytes() == before + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data["chat.modelDefaults"][model]["output_config"]["effort"] is None + assert _KIROCREW_EFFORT_OWNED_KEY not in data + + @pytest.mark.parametrize("recorded", [None, ""], ids=["null", "empty-string"]) + def test_malformed_record_entry_does_not_own_null_effort(self, tmp_path, recorded): + model = "claude-opus-4.7" + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_text( + json.dumps( + { + "chat.modelDefaults": {model: {"output_config": {"effort": None}}}, + _KIROCREW_EFFORT_OWNED_KEY: {model: recorded}, + } + ), + encoding="utf-8", + ) + + _write_cli_overlay(tmp_path, model, "max") + after_projection = json.loads(cli_json.read_text(encoding="utf-8")) + assert after_projection["chat.modelDefaults"][model]["output_config"]["effort"] is None + assert _KIROCREW_EFFORT_OWNED_KEY not in after_projection + + assert _clear_cli_overlay_effort(tmp_path, model, owned_only=True) is True + after_clear = json.loads(cli_json.read_text(encoding="utf-8")) + assert after_clear["chat.modelDefaults"][model]["output_config"]["effort"] is None + assert _KIROCREW_EFFORT_OWNED_KEY not in after_clear + + def test_same_operator_value_under_other_key_does_not_disown_projection(self, tmp_path): + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_text( + json.dumps( + {"chat.modelDefaults": {"claude-opus-4.7": {"reasoning": {"effort": "max"}}}} + ), + encoding="utf-8", + ) + + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data[_KIROCREW_EFFORT_OWNED_KEY] == {"claude-opus-4.7": "max"} + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + assert provider._apply_effort_overlay() is True + + assert _read_cli_overlay(tmp_path) == {"claude-opus-4.7": "max"} + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert _KIROCREW_EFFORT_OWNED_KEY not in data + def test_clear_removes_only_target_model(self, tmp_path): _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") _write_cli_overlay(tmp_path, "claude-opus-4.6", "high") @@ -211,14 +715,69 @@ def test_clear_missing_file_noop(self, tmp_path): _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") # must not raise assert _read_cli_overlay(tmp_path) == {} + def test_explicit_operator_clear_removes_an_unowned_entry(self, tmp_path): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / "cli.json").write_text( + json.dumps( + {"chat.modelDefaults": {"claude-opus-4.7": {"output_config": {"effort": "max"}}}} + ), + encoding="utf-8", + ) + + assert ( + _clear_cli_overlay_effort( + tmp_path, + "claude-opus-4.7", + owned_only=False, + ) + is True + ) + assert _read_cli_overlay(tmp_path) == {} + def test_clear_reports_success_only_when_the_file_stops_naming_the_model(self, tmp_path): # The postcondition is about the FILE, so an absent file and an absent - # entry are both successes -- there is nothing left to re-seed from. + # entry are both successes -- there is nothing left for a spawn to read. assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is True _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is True assert _read_cli_overlay(tmp_path) == {} + @_UNPARSEABLE_OVERLAY_CONTENTS + @pytest.mark.parametrize("model", ["claude-opus-4.7", None]) + def test_clear_leaves_a_file_no_parser_can_read_unchanged(self, tmp_path, model, contents): + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_bytes(contents) + + assert _clear_cli_overlay_effort(tmp_path, model) is True + assert cli_json.read_bytes() == contents + + def test_clear_leaves_a_malformed_cli_json_unchanged(self, tmp_path): + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + malformed = b"{ not json" + cli_json.write_bytes(malformed) + + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is True + assert cli_json.read_bytes() == malformed + + @pytest.mark.parametrize("model", ["claude-opus-4.7", None]) + def test_clear_leaves_an_undecodable_cli_json_unchanged(self, tmp_path, model): + # Non-UTF-8 bytes fail in read_text, before json sees them. That is a + # ValueError like malformed JSON, not the OSError of a failed read, and + # the file names no effort for anyone, so the answer is the same no-op. + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + undecodable = b'{"x": "\xff"}' + cli_json.write_bytes(undecodable) + + assert _clear_cli_overlay_effort(tmp_path, model) is True + assert cli_json.read_bytes() == undecodable + def test_clear_separates_a_malformed_file_from_a_failed_read(self, tmp_path, monkeypatch): # Two very different facts share one code path. A malformed file names no # effort for anyone and `_read_cli_overlay` reads it as {} too, so the @@ -231,17 +790,30 @@ def test_clear_separates_a_malformed_file_from_a_failed_read(self, tmp_path, mon (settings_dir / "cli.json").write_text("{ not json", encoding="utf-8") assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is True - _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") - real_read_text = Path.read_text + (settings_dir / "cli.json").write_text( + json.dumps( + { + "chat.modelDefaults": {"claude-opus-4.7": {"output_config": {"effort": "max"}}}, + _KIROCREW_EFFORT_OWNED_KEY: {"claude-opus-4.7": "max"}, + } + ), + encoding="utf-8", + ) + real_read = acp_provider.safe_read_file_bytes_nolink - def _flaky_read(self, *args, **kwargs): - if self.name == "cli.json": + def _flaky_read(raw, *args, **kwargs): + if Path(raw).name == "cli.json": raise OSError("sharing violation") - return real_read_text(self, *args, **kwargs) + return real_read(raw, *args, **kwargs) + + def _failed_pinned_read(_settings_fd, _cli_json): + raise OSError("sharing violation") + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "safe_read_file_bytes_nolink", _flaky_read) + temporary_patches.setattr(acp_provider, "_read_pinned_cli_json", _failed_pinned_read) + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False - monkeypatch.setattr(Path, "read_text", _flaky_read) - assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False - monkeypatch.undo() assert _read_cli_overlay(tmp_path) == {"claude-opus-4.7": "max"} def test_clear_reports_failure_only_when_the_lock_is_genuinely_stuck( @@ -261,9 +833,10 @@ def _busy(_work_dir, *, timeout=None): raise OSError("lock busy") yield # pragma: no cover - unreachable, keeps the generator shape - monkeypatch.setattr(acp_provider, "workspace_cli_settings_lock", _busy) - assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False - monkeypatch.undo() + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "locked_workspace_cli_settings", _busy) + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False + assert seen["timeout"] == acp_provider.CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS assert _read_cli_overlay(tmp_path) == {"claude-opus-4.7": "max"} @@ -285,9 +858,17 @@ def test_gpt_write_uses_reasoning_key_and_roundtrips(self, tmp_path): ("claude-opus-4.7", "output_config", "reasoning"), ], ) - def test_write_removes_stale_other_family_effort( + def test_write_preserves_other_family_effort( self, tmp_path, model: str, current_key: str, stale_key: str ): + """The other family key's ``effort`` is left alone, not swept. + + A sweep of that key would only matter to a reader that adopts whatever + ``effort`` the file holds, and no provider reads the overlay back into + its own effort map. kiro-cli ignores the wrong key, so the value there + is inert, and it may be the operator's: a sweep would be a destructive + write with nothing to protect. + """ settings_dir = tmp_path / ".kiro" / "settings" settings_dir.mkdir(parents=True) cli = settings_dir / "cli.json" @@ -299,10 +880,9 @@ def test_write_removes_stale_other_family_effort( _write_cli_overlay(tmp_path, model, "max") - assert _read_cli_overlay(tmp_path) == {model: "max"} model_cfg = json.loads(cli.read_text(encoding="utf-8"))["chat.modelDefaults"][model] assert model_cfg[current_key]["effort"] == "max" - assert model_cfg[stale_key] == {"preserved": True} + assert model_cfg[stale_key] == {"effort": "low", "preserved": True} def test_mixed_families_coexist(self, tmp_path): _write_cli_overlay(tmp_path, "claude-opus-4.7", "high") @@ -321,6 +901,146 @@ def test_clear_removes_gpt_reasoning_key(self, tmp_path): data = json.loads(cli.read_text(encoding="utf-8")) assert "gpt-5.6-luna" not in data.get("chat.modelDefaults", {}) + def test_projection_keeps_different_operator_level_and_automatic_clear(self, tmp_path): + model = "claude-opus-4.7" + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps( + { + "chat.modelDefaults": {model: {"output_config": {"effort": "low"}}}, + "operator": {"kept": True}, + } + ), + encoding="utf-8", + ) + before = cli_json.read_bytes() + + _write_cli_overlay(tmp_path, model, "max") + assert cli_json.read_bytes() == before + + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model=model, + ) + assert provider._apply_effort_overlay() is True + assert cli_json.read_bytes() == before + assert _read_cli_overlay(tmp_path) == {model: "low"} + + def test_projection_drops_only_stale_ownership_when_operator_value_differs(self, tmp_path): + model = "claude-opus-4.7" + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps( + { + "chat.modelDefaults": {model: {"output_config": {"effort": "low"}}}, + _KIROCREW_EFFORT_OWNED_KEY: {model: "max"}, + "operator": {"kept": True}, + } + ), + encoding="utf-8", + ) + + _write_cli_overlay(tmp_path, model, "high") + + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data["chat.modelDefaults"][model]["output_config"]["effort"] == "low" + assert data["operator"] == {"kept": True} + assert _KIROCREW_EFFORT_OWNED_KEY not in data + + @pytest.mark.parametrize( + "document", + [ + {"chat.modelDefaults": None}, + {"chat.modelDefaults": "operator-note"}, + {"chat.modelDefaults": {"claude-opus-4.7": "operator-note"}}, + {"chat.modelDefaults": {"claude-opus-4.7": {"output_config": "operator-note"}}}, + {"chat.modelDefaults": {"claude-opus-4.7": {"output_config": {"effort": 7}}}}, + ], + ids=[ + "model-defaults-null", + "model-defaults", + "model-entry", + "active-key", + "active-effort", + ], + ) + def test_projection_keeps_present_non_object_operator_shapes(self, tmp_path, document): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text(json.dumps(document), encoding="utf-8") + before = cli_json.read_bytes() + + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + + assert cli_json.read_bytes() == before + + @pytest.mark.parametrize( + ("document", "path"), + [ + ({"chat.modelDefaults": None, "other.key": 1}, "chat.modelDefaults"), + ({"chat.modelDefaults": "operator-note", "other.key": 1}, "chat.modelDefaults"), + ( + { + "chat.modelDefaults": {"claude-opus-4.7": "operator-note"}, + "other.key": 1, + }, + "chat.modelDefaults.claude-opus-4.7", + ), + ( + { + "chat.modelDefaults": {"claude-opus-4.7": {"output_config": "operator-note"}}, + "other.key": 1, + }, + "chat.modelDefaults.claude-opus-4.7.output_config", + ), + ], + ids=["model-defaults-null", "model-defaults-string", "model-entry", "active-key"], + ) + def test_explicit_default_refuses_a_non_object_parent_and_leaves_the_file( + self, tmp_path, document, path + ): + model = "claude-opus-4.7" + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text(json.dumps(document), encoding="utf-8") + before = cli_json.read_bytes() + + with pytest.raises(acp_provider._OverlayParentNotAnObject, match=path): + _write_cli_overlay(tmp_path, model, "max", replace_unowned=True) + + assert cli_json.read_bytes() == before + + def test_explicit_default_replaces_a_non_object_effort_value_under_an_object_key( + self, tmp_path + ): + model = "claude-opus-4.7" + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps( + { + "chat.modelDefaults": {model: {"output_config": {"effort": 7}}}, + "other.key": 1, + } + ), + encoding="utf-8", + ) + + _write_cli_overlay(tmp_path, model, "max", replace_unowned=True) + + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data["chat.modelDefaults"][model]["output_config"]["effort"] == "max" + assert data[_KIROCREW_EFFORT_OWNED_KEY] == {model: "max"} + assert data["other.key"] == 1 + class TestFactoryEffortThreading: """The provider factory must thread the slot's reasoning_effort_override @@ -778,3 +1498,68 @@ def test_invalid_config_default_ignored(self): model_override="claude-opus-4.7", ) assert kwargs.get("effort_per_model") == {} + + +class TestCodeReviewSageOverlayKeepsAcpOwnership(unittest.TestCase): + """Code Review Sage's overlay writer against the `AcpProvider` ownership record.""" + + def test_write_effort_overlay_keeps_acp_owned_entry_owned(self): + with tempfile.TemporaryDirectory() as tmp: + work_dir = Path(tmp) + first_model = "claude-opus-4.7" + second_model = "claude-sonnet-4.6" + acp_provider._write_cli_overlay(work_dir, first_model, "max") + + rp._write_effort_overlay(tmp, second_model, "high") + + cli_json = work_dir / ".kiro" / "settings" / "cli.json" + written = json.loads(cli_json.read_text(encoding="utf-8")) + self.assertEqual(written["kirocrew.effortOwnedStamp"], int(cli_json.stat().st_mtime)) + self.assertTrue( + acp_provider._clear_cli_overlay_effort(work_dir, first_model, owned_only=True) + ) + data = json.loads( + (work_dir / ".kiro" / "settings" / "cli.json").read_text(encoding="utf-8") + ) + self.assertNotIn(first_model, data.get("chat.modelDefaults", {})) + self.assertEqual( + data["chat.modelDefaults"][second_model]["output_config"]["effort"], + "high", + ) + + def test_operator_save_after_publish_voids_acp_owned_clear(self): + with tempfile.TemporaryDirectory() as tmp: + work_dir = Path(tmp) + first_model = "claude-opus-4.7" + second_model = "claude-sonnet-4.6" + acp_provider._write_cli_overlay(work_dir, first_model, "max") + cli_json = work_dir / ".kiro" / "settings" / "cli.json" + real_atomic_write_text = rp.store.atomic_write_text + operator_saves = [] + + def _publish_then_operator_save(target, text, **kwargs): + real_atomic_write_text(target, text, **kwargs) + Path(target).write_text(text, encoding="utf-8") + operator_saves.append(Path(target)) + + with unittest.mock.patch.object( + rp.store, "atomic_write_text", _publish_then_operator_save + ): + rp._write_effort_overlay(tmp, second_model, "high") + + written = json.loads(cli_json.read_text(encoding="utf-8")) + self.assertEqual([path.resolve() for path in operator_saves], [cli_json.resolve()]) + self.assertNotEqual(written["kirocrew.effortOwnedStamp"], int(cli_json.stat().st_mtime)) + self.assertTrue( + acp_provider._clear_cli_overlay_effort(work_dir, first_model, owned_only=True) + ) + after = json.loads(cli_json.read_text(encoding="utf-8")) + self.assertEqual( + after["chat.modelDefaults"][first_model]["output_config"]["effort"], + "max", + ) + self.assertEqual( + after["chat.modelDefaults"][second_model]["output_config"]["effort"], + "high", + ) + self.assertNotIn("kirocrew.effortOwned", after) diff --git a/test/test_effort_overlay_paths.py b/test/test_effort_overlay_paths.py new file mode 100644 index 00000000000..563a9e04b8b --- /dev/null +++ b/test/test_effort_overlay_paths.py @@ -0,0 +1,1605 @@ +"""cli.json overlay tests: the paths the overlay writer follows and refuses. + +Links and reparse points on ``.kiro``, ``settings`` and ``cli.json``, sensitive +targets, the lock that pins the directories, and every swap or retarget raced +against the walk, the lock check, the read and the publish: nothing is created, +read or replaced outside the workspace's own settings directory. +""" + +from __future__ import annotations + +import json +import os +from contextlib import contextmanager +from pathlib import Path, PureWindowsPath + +import pytest +from effort_helpers import _read_cli_overlay + +# O_NOFOLLOW is part of pinned_fs.supports_pinned_walk's required probe. +from conftest import make_dir_link, requires_o_nofollow, requires_symlinks +from kiro_crew import platform_compat +from kiro_crew.acp.types import ACP_BACKEND_KIRO +from kiro_crew.providers import acp as acp_provider +from kiro_crew.providers.acp import ( + _clear_cli_overlay_effort, + _write_cli_overlay, + _write_tool_search_overlay, +) +from kiro_crew.workspace_cli_settings import CLI_SETTINGS_LOCK_NAME, workspace_cli_settings_lock + + +def _tree(root: Path) -> list[str]: + """Every path under *root*, relative and sorted, so a nested addition shows.""" + return sorted(str(path.relative_to(root)) for path in root.rglob("*")) + + +def _set_test_home(monkeypatch, home: Path) -> None: + """Anchor sensitive-path checks to *home* without a cached prior HOME.""" + from kiro_crew import security + + monkeypatch.setenv("HOME", str(home)) + security._home_targets_cache.clear() + + +class TestCliOverlay: + @pytest.mark.parametrize( + "link_kind", [pytest.param("symlink", marks=requires_symlinks), "hardlink"] + ) + def test_overlay_link_is_never_read_or_replaced(self, tmp_path, link_kind): + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + target = tmp_path / "outside.json" + secret = b'{"token": "SECRET"}' + target.write_bytes(secret) + cli_json = settings_dir / "cli.json" + try: + if link_kind == "symlink": + cli_json.symlink_to(target) + else: + os.link(target, cli_json) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"{link_kind} is unavailable: {exc}") + + with pytest.raises(OSError, match="link|hardlink|regular"): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False + + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + assert provider._apply_effort_overlay() is False + assert target.read_bytes() == secret + assert not any( + path != cli_json + and path.is_file() + and not path.is_symlink() + and b"SECRET" in path.read_bytes() + for path in settings_dir.iterdir() + ) + + @requires_o_nofollow + def test_overlay_renamed_after_pinned_open_is_never_replaced(self, tmp_path, monkeypatch): + from kiro_crew import pinned_fs + + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + cli_json.write_bytes(b'{"chat.modelDefaults": {}}') + replacement = settings_dir / "replacement.json" + replacement_bytes = [b'{"replacement": "write"}', b'{"replacement": "clear"}'] + real_open = os.open + + def _replace_after_pinned_open(path, flags, mode=0o777, *, dir_fd=None): + fd = real_open(path, flags, mode, dir_fd=dir_fd) + if path == "cli.json" and dir_fd is not None: + replacement.write_bytes(replacement_bytes.pop(0)) + os.replace(replacement, cli_json) + return fd + + # Both capability probes read ``os.open in os.supports_dir_fd``, which the wrapped + # ``os.open`` fails, so the pinned branch is pinned on explicitly. + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: True) + monkeypatch.setattr( + acp_provider.atomic_write_module, "pinned_parent_replace_supported", lambda: True + ) + monkeypatch.setattr(acp_provider.os, "open", _replace_after_pinned_open) + + with pytest.raises(OSError, match="link|hardlink|regular"): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False + assert replacement_bytes == [] + assert cli_json.read_bytes() == b'{"replacement": "clear"}' + + @requires_symlinks + def test_dangling_cli_json_link_is_refused(self, tmp_path): + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + try: + cli_json.symlink_to(tmp_path / "missing.json") + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + + with pytest.raises(OSError, match="link|hardlink|regular"): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False + assert cli_json.is_symlink() + + @pytest.mark.parametrize( + ("target", "expected"), + [ + (PureWindowsPath(r"\\?\C:\w\x"), PureWindowsPath(r"C:\w\x")), + (PureWindowsPath(r"\??\C:\w\x"), PureWindowsPath(r"C:\w\x")), + ( + PureWindowsPath(r"\\?\UNC\host\share\x"), + PureWindowsPath(r"\\host\share\x"), + ), + (PureWindowsPath(r"\\host\share\x"), PureWindowsPath(r"\\host\share\x")), + (PureWindowsPath(r"\rooted"), PureWindowsPath(r"\rooted")), + (PureWindowsPath(r"D:\other"), PureWindowsPath(r"D:\other")), + ], + ) + def test_windows_link_target_normalization_is_lexical(self, target, expected): + from kiro_crew.workspace_cli_settings import _normalize_windows_link_target + + assert _normalize_windows_link_target(target) == expected + + @pytest.mark.parametrize("redirect", [".kiro", "settings"]) + @pytest.mark.parametrize("absolute", [False, True]) + def test_settings_link_target_inside_work_dir_is_resolved(self, tmp_path, redirect, absolute): + from kiro_crew.workspace_cli_settings import _settings_dir_within_work_dir + + work_dir = tmp_path / "work" + work_dir.mkdir() + if redirect == ".kiro": + target = work_dir / "inside" / ".kiro" + target.mkdir(parents=True) + link = work_dir / ".kiro" + else: + (work_dir / ".kiro").mkdir() + target = work_dir / "inside" / "settings" + target.mkdir(parents=True) + link = work_dir / ".kiro" / "settings" + link_target = target if absolute else os.path.relpath(target, link.parent) + platform_compat.symlink_or_junction(link_target, link) + + expected = target / "settings" if redirect == ".kiro" else target + assert _settings_dir_within_work_dir(work_dir) == expected + + def test_relative_link_parent_component_that_stays_inside_is_resolved(self, tmp_path): + from kiro_crew.workspace_cli_settings import _settings_dir_within_work_dir + + work_dir = tmp_path / "work" + target = work_dir / "inside" / "settings" + target.mkdir(parents=True) + (work_dir / ".kiro").mkdir() + platform_compat.symlink_or_junction( + os.path.join("..", "inside", "settings"), work_dir / ".kiro" / "settings" + ) + + assert _settings_dir_within_work_dir(work_dir) == target + + def test_relative_link_parent_component_that_escapes_is_refused(self, tmp_path): + from kiro_crew.workspace_cli_settings import _settings_dir_within_work_dir + + work_dir = tmp_path / "work" + work_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + platform_compat.symlink_or_junction(os.path.join("..", "outside"), work_dir / ".kiro") + + with pytest.raises(OSError, match="resolves outside"): + _settings_dir_within_work_dir(work_dir) + + def test_absolute_link_outside_work_dir_is_refused(self, tmp_path): + from kiro_crew.workspace_cli_settings import _settings_dir_within_work_dir + + work_dir = tmp_path / "work" + work_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + platform_compat.symlink_or_junction(outside, work_dir / ".kiro") + + with pytest.raises(OSError, match="resolves outside"): + _settings_dir_within_work_dir(work_dir) + + def test_dangling_link_to_missing_inside_folder_is_returned_for_creation(self, tmp_path): + from kiro_crew.workspace_cli_settings import _settings_dir_within_work_dir + + work_dir = tmp_path / "work" + target = work_dir / "inside" / "missing" + target.mkdir(parents=True) + platform_compat.symlink_or_junction(target, work_dir / ".kiro") + target.rmdir() + + assert _settings_dir_within_work_dir(work_dir) == target / "settings" + + def test_non_directory_settings_component_is_refused(self, tmp_path): + from kiro_crew.workspace_cli_settings import _settings_dir_within_work_dir + + work_dir = tmp_path / "work" + work_dir.mkdir() + (work_dir / ".kiro").write_text("not a directory", encoding="utf-8") + + with pytest.raises(OSError, match="not a directory"): + _settings_dir_within_work_dir(work_dir) + + @pytest.mark.parametrize("redirect", [".kiro", "settings"]) + @pytest.mark.parametrize("with_existing_file", [False, True]) + def test_redirected_settings_directory_is_never_written( + self, tmp_path, redirect, with_existing_file + ): + work_dir = tmp_path / "work" + work_dir.mkdir() + outside = tmp_path / "outside" + try: + if redirect == ".kiro": + external_settings = outside / ".kiro" / "settings" + external_settings.mkdir(parents=True) + platform_compat.symlink_or_junction(outside / ".kiro", work_dir / ".kiro") + else: + external_settings = outside / "settings" + external_settings.mkdir(parents=True) + (work_dir / ".kiro").mkdir() + platform_compat.symlink_or_junction( + external_settings, work_dir / ".kiro" / "settings" + ) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + cli_json = external_settings / "cli.json" + before = b'{"token": "SECRET"}' + if with_existing_file: + cli_json.write_bytes(before) + + with pytest.raises(OSError, match="resolves outside the work directory"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="resolves outside the work directory"): + _write_tool_search_overlay(work_dir, True) + # The gateway does not inspect the outside folder, so clear reports that + # it could not change the file whether or not one exists there. + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + if with_existing_file: + assert cli_json.read_bytes() == before + else: + assert not cli_json.exists() + assert not (external_settings / CLI_SETTINGS_LOCK_NAME).exists() + + @pytest.mark.parametrize("redirect", [".kiro", "settings"]) + def test_settings_directory_linked_inside_work_dir_is_used(self, tmp_path, redirect): + work_dir = tmp_path / "work" + work_dir.mkdir() + try: + if redirect == ".kiro": + target_settings = work_dir / "shared" / ".kiro" / "settings" + target_settings.mkdir(parents=True) + platform_compat.symlink_or_junction( + work_dir / "shared" / ".kiro", work_dir / ".kiro" + ) + else: + target_settings = work_dir / "shared-settings" + target_settings.mkdir() + (work_dir / ".kiro").mkdir() + platform_compat.symlink_or_junction( + target_settings, work_dir / ".kiro" / "settings" + ) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + cli_json = target_settings / "cli.json" + cli_json.write_bytes(b'{"kept": true}') + + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + _write_tool_search_overlay(work_dir, True) + + document = json.loads(cli_json.read_bytes()) + assert document["kept"] is True + assert document["chat.modelDefaults"]["claude-opus-4.7"]["output_config"]["effort"] == "max" + assert document["toolSearch.enabled"] is True + assert _read_cli_overlay(work_dir) == {"claude-opus-4.7": "max"} + assert (target_settings / CLI_SETTINGS_LOCK_NAME).is_file() + + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is True + assert _read_cli_overlay(work_dir) == {} + assert json.loads(cli_json.read_bytes())["kept"] is True + assert not (tmp_path / ".kiro").exists() + + @requires_o_nofollow + @pytest.mark.parametrize("pinned_walk", [True, False], ids=["pinned", "by-name"]) + @pytest.mark.parametrize("absolute", [False, True], ids=["relative", "absolute"]) + def test_sensitive_settings_link_is_refused_before_lock_or_overlay_read( + self, tmp_path, monkeypatch, pinned_walk, absolute + ): + from kiro_crew import pinned_fs, workspace_cli_settings + + work_dir = tmp_path / "work" + sensitive_dir = work_dir / ".aws" + sensitive_dir.mkdir(parents=True) + cli_json = sensitive_dir / "cli.json" + sentinel = b'{"token": "NEVER-READ"}' + cli_json.write_bytes(sentinel) + (work_dir / ".kiro").mkdir() + target = sensitive_dir if absolute else Path("..") / ".aws" + platform_compat.symlink_or_junction(target, work_dir / ".kiro" / "settings") + _set_test_home(monkeypatch, work_dir) + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: pinned_walk) + + pin_attempts = [] + read_attempts = [] + real_pinned_walk = workspace_cli_settings._pinned_settings_dir_fd + real_by_name_pin = platform_compat.pin_directory + + def _record_pinned_walk(*args, **kwargs): + pin_attempts.append("pinned") + return real_pinned_walk(*args, **kwargs) + + def _record_by_name_pin(*args, **kwargs): + pin_attempts.append("by-name") + return real_by_name_pin(*args, **kwargs) + + def _unexpected_read(*_args, **_kwargs): + read_attempts.append("cli.json") + raise AssertionError("sensitive cli.json bytes must not be read") + + monkeypatch.setattr(workspace_cli_settings, "_pinned_settings_dir_fd", _record_pinned_walk) + monkeypatch.setattr(platform_compat, "pin_directory", _record_by_name_pin) + monkeypatch.setattr(acp_provider, "_read_pinned_cli_json", _unexpected_read) + before = _tree(sensitive_dir) + + with pytest.raises(OSError, match="sensitive path"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="sensitive path"): + _write_tool_search_overlay(work_dir, True) + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + assert pin_attempts == [] + assert read_attempts == [] + assert cli_json.read_bytes() == sentinel + assert _tree(sensitive_dir) == before + assert not (sensitive_dir / CLI_SETTINGS_LOCK_NAME).exists() + + @requires_o_nofollow + @pytest.mark.parametrize("pinned_walk", [True, False], ids=["pinned", "by-name"]) + def test_sensitive_settings_link_with_no_overlay_creates_nothing( + self, tmp_path, monkeypatch, pinned_walk + ): + from kiro_crew import pinned_fs + + work_dir = tmp_path / "work" + sensitive_dir = work_dir / ".aws" + sensitive_dir.mkdir(parents=True) + (work_dir / ".kiro").mkdir() + platform_compat.symlink_or_junction(Path("..") / ".aws", work_dir / ".kiro" / "settings") + _set_test_home(monkeypatch, work_dir) + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: pinned_walk) + + with pytest.raises(OSError, match="sensitive path"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="sensitive path"): + _write_tool_search_overlay(work_dir, True) + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is True + + assert _tree(sensitive_dir) == [] + + @requires_o_nofollow + def test_pinned_cli_json_refuses_sensitive_descriptor_before_read(self, tmp_path, monkeypatch): + from kiro_crew import pinned_fs + + work_dir = tmp_path / "work" + sensitive_dir = work_dir / ".aws" + sensitive_dir.mkdir(parents=True) + cli_json = sensitive_dir / "cli.json" + sentinel = b'{"token": "NEVER-READ"}' + cli_json.write_bytes(sentinel) + _set_test_home(monkeypatch, work_dir) + read_attempts = [] + + def _unexpected_fdopen(*_args, **_kwargs): + read_attempts.append("cli.json") + raise AssertionError("sensitive cli.json descriptor must not become a reader") + + monkeypatch.setattr(acp_provider.os, "fdopen", _unexpected_fdopen) + settings_fd = os.open(sensitive_dir, pinned_fs.dir_flags()) + try: + with pytest.raises(OSError, match="left unchanged"): + acp_provider._read_pinned_cli_json(settings_fd, cli_json) + finally: + os.close(settings_fd) + + assert read_attempts == [] + assert cli_json.read_bytes() == sentinel + + def test_settings_directory_loop_is_refused(self, tmp_path): + work_dir = tmp_path / "work" + work_dir.mkdir() + try: + platform_compat.symlink_or_junction(work_dir / ".kiro", work_dir / ".kiro") + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + before = _tree(tmp_path) + + with pytest.raises(OSError, match="resolves outside the work directory or loops"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="resolves outside the work directory or loops"): + _write_tool_search_overlay(work_dir, True) + # The gateway cannot prove that no file exists without following the loop, + # so the clear reports that it could not change the overlay. + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + assert _tree(tmp_path) == before + + def test_work_dir_resolving_elsewhere_after_the_check_is_refused(self, tmp_path, monkeypatch): + """A work dir that resolves elsewhere between the two resolutions is refused, not crashed.""" + from kiro_crew import workspace_cli_settings + + work_dir = tmp_path / "work" + (work_dir / ".kiro" / "settings").mkdir(parents=True) + moved = tmp_path / "moved" + moved.mkdir() + real_resolved_work_dir = workspace_cli_settings._resolved_work_dir + calls = 0 + + def _elsewhere_on_the_second_call(path): + nonlocal calls + calls += 1 + return moved if calls == 2 else real_resolved_work_dir(path) + + monkeypatch.setattr( + workspace_cli_settings, "_resolved_work_dir", _elsewhere_on_the_second_call + ) + before = _tree(tmp_path) + + with pytest.raises(OSError, match="changed while it was opened"): + with workspace_cli_settings_lock(work_dir): + pytest.fail("a work dir that moved must not be locked") + + assert calls == 2 + assert _tree(tmp_path) == before + + @pytest.mark.parametrize("pinned_walk", [True, False]) + @pytest.mark.parametrize( + ("swap", "swap_at_check"), [("link-target", 1), ("resolved-folder", 2)] + ) + def test_inside_link_swapped_outside_after_resolution_is_refused( + self, tmp_path, monkeypatch, pinned_walk, swap, swap_at_check + ): + """A chain resolved inside the work dir is refused once a part of it points outside. + + ``link-target`` re-points the ``.kiro`` link after the first resolution, which the + second resolution refuses. ``resolved-folder`` replaces the resolved ``inside-kiro`` + folder with a link after the second resolution, which the walk refuses because it + opens the resolved names and never follows a link. On Windows the walk's pin on + ``inside-kiro`` refuses that delete, so the swap never happens and the lock holds the + folder inside the work dir. + """ + from kiro_crew import pinned_fs, workspace_cli_settings + + if pinned_walk and not pinned_fs.supports_pinned_walk(): + pytest.skip("descriptor-relative directory walks are unavailable here") + work_dir = tmp_path / "work" + inside_kiro = work_dir / "inside-kiro" + (inside_kiro / "settings").mkdir(parents=True) + outside = tmp_path / "outside" + (outside / "settings").mkdir(parents=True) + secret = b'{"token": "SECRET"}' + (outside / "settings" / "cli.json").write_bytes(secret) + try: + platform_compat.symlink_or_junction(inside_kiro, work_dir / ".kiro") + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + real_settings_dir = workspace_cli_settings._settings_dir_within_work_dir + checks = 0 + swap_blocked = False + + def _swap_after_check(path): + nonlocal checks, swap_blocked + settings_dir = real_settings_dir(path) + checks += 1 + if checks == swap_at_check: + try: + if swap == "link-target": + (work_dir / ".kiro").unlink() + platform_compat.symlink_or_junction(outside, work_dir / ".kiro") + else: + (inside_kiro / "settings").rmdir() + try: + inside_kiro.rmdir() + except PermissionError: + swap_blocked = True + else: + platform_compat.symlink_or_junction(outside, inside_kiro) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + return settings_dir + + before = _tree(outside) + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: pinned_walk) + monkeypatch.setattr( + workspace_cli_settings, "_settings_dir_within_work_dir", _swap_after_check + ) + + failure = None + yielded = None + try: + with workspace_cli_settings_lock(work_dir) as cli_json: + yielded = cli_json + if not swap_blocked: + pytest.fail("a settings chain swapped outside the work dir must not be locked") + except OSError as exc: + failure = exc + + assert checks >= swap_at_check + if swap_blocked: + assert failure is None + assert yielded == work_dir.resolve() / "inside-kiro" / "settings" / "cli.json" + else: + assert failure is not None + assert "settings directory" in str(failure) + assert _tree(outside) == before + assert (outside / "settings" / "cli.json").read_bytes() == secret + + def test_settings_swap_after_lock_check_cannot_read_outside_overlay( + self, tmp_path, monkeypatch + ): + from kiro_crew import workspace_cli_settings + from kiro_crew.providers.acp import _write_tool_search_overlay + + work_dir = tmp_path / "work" + settings_dir = work_dir / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + outside = tmp_path / "outside" + outside.mkdir() + outside_cli_json = outside / "cli.json" + secret = b'{"token": "SECRET"}' + outside_cli_json.write_bytes(secret) + settings_dir.rmdir() + try: + platform_compat.symlink_or_junction(outside, settings_dir) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr( + workspace_cli_settings, + "_settings_dir_within_work_dir", + lambda _work_dir: settings_dir, + ) + with pytest.raises(OSError, match="settings directory"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="settings directory"): + _write_tool_search_overlay(work_dir, True) + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + assert outside_cli_json.read_bytes() == secret + assert not (outside / CLI_SETTINGS_LOCK_NAME).exists() + assert not any( + path.is_file() and not path.is_symlink() and b"SECRET" in path.read_bytes() + for path in work_dir.rglob("*") + ) + + @pytest.mark.parametrize("redirect", [".kiro", "settings"]) + def test_by_name_directory_swap_after_second_check_never_creates_outside_lock( + self, tmp_path, monkeypatch, redirect + ): + from kiro_crew import pinned_fs, workspace_cli_settings + + work_dir = tmp_path / "work" + work_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + if redirect == ".kiro": + (outside / "settings").mkdir() + else: + (work_dir / ".kiro").mkdir() + real_settings_dir = workspace_cli_settings._settings_dir_within_work_dir + checks = 0 + swap_blocked = False + + def _swap_after_second_check(path): + nonlocal checks, swap_blocked + settings_dir = real_settings_dir(path) + checks += 1 + if checks == 2: + try: + settings_dir.mkdir(parents=True, exist_ok=True) + if redirect == ".kiro": + settings_dir.rmdir() + kiro_dir = work_dir / ".kiro" + try: + kiro_dir.rmdir() + except PermissionError: + swap_blocked = True + else: + platform_compat.symlink_or_junction(outside, kiro_dir) + else: + settings_dir.rmdir() + platform_compat.symlink_or_junction(outside, settings_dir) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + return settings_dir + + before = _tree(outside) + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr( + workspace_cli_settings, "_settings_dir_within_work_dir", _swap_after_second_check + ) + + failure = None + yielded = None + try: + with workspace_cli_settings_lock(work_dir) as cli_json: + yielded = cli_json + if not swap_blocked: + pytest.fail("a swapped settings directory must not be locked") + except OSError as exc: + failure = exc + + if swap_blocked: + assert failure is None + assert yielded == work_dir.resolve() / ".kiro" / "settings" / "cli.json" + assert _tree(outside) == before + else: + assert failure is not None + assert "settings directory" in str(failure) + + def test_by_name_first_check_swap_never_creates_outside_settings(self, tmp_path, monkeypatch): + from kiro_crew import pinned_fs, workspace_cli_settings + + work_dir = tmp_path / "work" + work_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + real_settings_dir = workspace_cli_settings._settings_dir_within_work_dir + checks = 0 + + def _swap_after_first_check(path): + nonlocal checks + settings_dir = real_settings_dir(path) + checks += 1 + if checks == 1: + try: + platform_compat.symlink_or_junction(outside, work_dir / ".kiro") + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + return settings_dir + + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr( + workspace_cli_settings, "_settings_dir_within_work_dir", _swap_after_first_check + ) + + with pytest.raises(OSError, match="settings directory"): + with workspace_cli_settings_lock(work_dir): + pytest.fail("a swapped .kiro directory must not be locked") + + assert not (outside / "settings").exists() + + def test_by_name_work_dir_alias_retarget_never_reaches_a_sibling_workspace( + self, tmp_path, monkeypatch + ): + from kiro_crew import pinned_fs + + first = tmp_path / "first-workspace" + second = tmp_path / "second-workspace" + first.mkdir() + (second / ".kiro" / "settings").mkdir(parents=True) + sibling_cli_json = second / ".kiro" / "settings" / "cli.json" + operator_document = b'{"chat.modelDefaults": {"m": {"output_config": {"effort": "max"}}}}' + sibling_cli_json.write_bytes(operator_document) + alias = tmp_path / "alias" + try: + make_dir_link(alias, first) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + + real_pin_directory = platform_compat.pin_directory + pins = 0 + + def _retarget_alias_on_first_pin(path): + nonlocal pins + pinned = real_pin_directory(path) + pins += 1 + if pins == 1: + try: + platform_compat.unlink_link_or_junction(alias) + make_dir_link(alias, second) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + return pinned + + before = _tree(second) + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr(platform_compat, "pin_directory", _retarget_alias_on_first_pin) + + yielded = None + try: + with workspace_cli_settings_lock(alias) as cli_json: + yielded = cli_json + cli_json.write_bytes(b"{}") + except OSError as exc: + assert "settings directory" in str(exc) + + assert pins >= 1 + if yielded is not None: + assert yielded == first.resolve() / ".kiro" / "settings" / "cli.json" + assert sibling_cli_json.read_bytes() == operator_document + assert _tree(second) == before + + def test_work_dir_alias_retarget_after_the_second_resolution_stays_in_the_first_workspace( + self, tmp_path, monkeypatch + ): + from kiro_crew import workspace_cli_settings + + first = tmp_path / "first-workspace" + second = tmp_path / "second-workspace" + first.mkdir() + second_cli_json = second / ".kiro" / "settings" / "cli.json" + second_cli_json.parent.mkdir(parents=True) + operator_document = b'{"chat.modelDefaults": {"m": {"output_config": {"effort": "max"}}}}' + second_cli_json.write_bytes(operator_document) + alias = tmp_path / "alias" + try: + make_dir_link(alias, first) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + + real_settings_dir = workspace_cli_settings._settings_dir_within_work_dir + resolutions = 0 + + def _retarget_after_second_resolution(path): + nonlocal resolutions + settings_dir = real_settings_dir(path) + resolutions += 1 + if resolutions == 2: + try: + platform_compat.unlink_link_or_junction(alias) + make_dir_link(alias, second) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + return settings_dir + + before = _tree(second) + monkeypatch.setattr( + workspace_cli_settings, + "_settings_dir_within_work_dir", + _retarget_after_second_resolution, + ) + + with workspace_cli_settings_lock(alias) as cli_json: + assert cli_json == first.resolve() / ".kiro" / "settings" / "cli.json" + cli_json.write_bytes(b"{}") + + assert resolutions == 2 + assert second_cli_json.read_bytes() == operator_document + assert _tree(second) == before + assert (first / ".kiro" / "settings" / "cli.json").read_bytes() == b"{}" + + def test_by_name_fallback_creates_and_pins_fresh_settings(self, tmp_path, monkeypatch): + from kiro_crew import pinned_fs + + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + + with workspace_cli_settings_lock(tmp_path) as cli_json: + assert cli_json == tmp_path / ".kiro" / "settings" / "cli.json" + assert cli_json.parent.is_dir() + assert not (tmp_path / ".kiro").is_symlink() + assert not cli_json.parent.is_symlink() + + def test_by_name_lock_pins_directories_until_after_overlay_publish(self, tmp_path, monkeypatch): + from kiro_crew import pinned_fs, platform_compat + + pins = [] + checked = [] + real_pin_directory = platform_compat.pin_directory + real_open_lock_file = platform_compat.open_lock_file + real_atomic_write = acp_provider.atomic_write + + def _record_pin_directory(path): + descriptor = real_pin_directory(path) + info = os.fstat(descriptor) + pins.append((Path(path).name, descriptor, (info.st_dev, info.st_ino))) + return descriptor + + def _assert_lock_pins_held(when): + assert [name for name, _, _ in pins[:2]] == [".kiro", "settings"] + for _, descriptor, identity in pins[:2]: + info = os.fstat(descriptor) + assert (info.st_dev, info.st_ino) == identity + checked.append(when) + + @contextmanager + def _opening_lock(path): + if Path(path).name == CLI_SETTINGS_LOCK_NAME: + _assert_lock_pins_held("lock") + with real_open_lock_file(path) as lock_file: + yield lock_file + + def _publishing(path, content, **kwargs): + if Path(path).name == "cli.json": + _assert_lock_pins_held("publish") + return real_atomic_write(path, content, **kwargs) + + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr(platform_compat, "pin_directory", _record_pin_directory) + monkeypatch.setattr(platform_compat, "open_lock_file", _opening_lock) + monkeypatch.setattr(acp_provider, "atomic_write", _publishing) + + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + + assert checked == ["lock", "publish"] + for _, descriptor, identity in pins[:2]: + try: + info = os.fstat(descriptor) + except OSError: + continue + # A released number can be reused by a later open; the pinned folder cannot. + assert (info.st_dev, info.st_ino) != identity + + @pytest.mark.parametrize("redirect", [".kiro", "settings"]) + def test_by_name_pinned_directory_identity_rejects_a_swap_before_lock_open( + self, tmp_path, monkeypatch, redirect + ): + from kiro_crew import pinned_fs, platform_compat + + work_dir = tmp_path / "work" + settings_dir = work_dir / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + outside = tmp_path / "outside" + outside.mkdir() + if redirect == ".kiro": + (outside / "settings").mkdir() + real_open_lock_file = platform_compat.open_lock_file + swap_blocked = False + + @contextmanager + def _swap_before_lock_open(path): + nonlocal swap_blocked + if redirect == ".kiro": + try: + settings_dir.rmdir() + kiro_dir = work_dir / ".kiro" + kiro_dir.rmdir() + except PermissionError: + swap_blocked = True + else: + platform_compat.symlink_or_junction(outside, kiro_dir) + else: + try: + settings_dir.rmdir() + except PermissionError: + swap_blocked = True + else: + platform_compat.symlink_or_junction(outside, settings_dir) + with real_open_lock_file(path) as lock_file: + yield lock_file + + before = _tree(outside) + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr(platform_compat, "open_lock_file", _swap_before_lock_open) + + failure = None + try: + with workspace_cli_settings_lock(work_dir) as cli_json: + if not swap_blocked: + pytest.fail("a swapped settings directory must not be locked") + assert cli_json == work_dir / ".kiro" / "settings" / "cli.json" + except OSError as exc: + failure = exc + + if swap_blocked: + assert failure is None + assert _tree(outside) == before + else: + assert failure is not None + assert "settings directory changed while it was opened" in str(failure) + + @pytest.mark.parametrize("when", ["at_its_pin", "after_settings_pin"]) + def test_by_name_reparse_point_on_held_kiro_is_refused(self, tmp_path, monkeypatch, when): + import stat + from types import SimpleNamespace + + from kiro_crew import pinned_fs, platform_compat + + work_dir = tmp_path / "work" + work_dir.mkdir() + real_pin_directory = platform_compat.pin_directory + real_lstat_by_name = pinned_fs.lstat_by_name + reparsed = [] + lock_opens = [] + + def _pin_directory(path): + descriptor = real_pin_directory(path) + pinned = Path(path).name + if (when, pinned) in {("at_its_pin", ".kiro"), ("after_settings_pin", "settings")}: + reparsed.append(path) + return descriptor + + @contextmanager + def _open_lock_file(path): + lock_opens.append(path) + yield None + + # The held .kiro reports what Windows reports for an empty folder that took a reparse + # point in place: the same file id, the reparse attribute. + def _lstat_by_name(target): + info = real_lstat_by_name(target) + if reparsed and info is not None and Path(target).name == ".kiro": + return SimpleNamespace( + st_mode=info.st_mode, + st_dev=info.st_dev, + st_ino=info.st_ino, + st_file_attributes=stat.FILE_ATTRIBUTE_REPARSE_POINT, + ) + return info + + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr(platform_compat, "pin_directory", _pin_directory) + monkeypatch.setattr(platform_compat, "open_lock_file", _open_lock_file) + monkeypatch.setattr(pinned_fs, "lstat_by_name", _lstat_by_name) + + with pytest.raises(OSError, match="settings directory changed while it was opened"): + with workspace_cli_settings_lock(work_dir): + pytest.fail("a .kiro folder that took a reparse point must not be locked") + + assert lock_opens == [] + # Refused at its own check, nothing is created through a .kiro that is a reparse point. + assert (work_dir / ".kiro" / "settings").exists() is (when == "after_settings_pin") + + @pytest.mark.parametrize("when", ["after_pins", "at_lock_open"]) + def test_by_name_reparse_point_on_held_settings_is_refused(self, tmp_path, monkeypatch, when): + import stat + from types import SimpleNamespace + + from kiro_crew import pinned_fs, platform_compat + + work_dir = tmp_path / "work" + work_dir.mkdir() + real_pin_directory = platform_compat.pin_directory + real_open_lock_file = platform_compat.open_lock_file + real_lstat_by_name = pinned_fs.lstat_by_name + reparsed = [] + lock_opens = [] + + def _pin_directory(path): + descriptor = real_pin_directory(path) + if when == "after_pins" and Path(path).name == "settings": + reparsed.append(path) + return descriptor + + @contextmanager + def _open_lock_file(path): + lock_opens.append(path) + if when == "at_lock_open": + reparsed.append(path) + with real_open_lock_file(path) as lock_file: + yield lock_file + + # Linux cannot set a reparse point, so the held folder reports what Windows reports + # for an empty folder that took one in place: the same file id, the reparse attribute. + def _lstat_by_name(target): + info = real_lstat_by_name(target) + if reparsed and info is not None and Path(target).name == "settings": + return SimpleNamespace( + st_mode=info.st_mode, + st_dev=info.st_dev, + st_ino=info.st_ino, + st_file_attributes=stat.FILE_ATTRIBUTE_REPARSE_POINT, + ) + return info + + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr(platform_compat, "pin_directory", _pin_directory) + monkeypatch.setattr(platform_compat, "open_lock_file", _open_lock_file) + monkeypatch.setattr(pinned_fs, "lstat_by_name", _lstat_by_name) + + with pytest.raises(OSError, match="settings directory changed while it was opened"): + with workspace_cli_settings_lock(work_dir): + pytest.fail("a settings folder that took a reparse point must not be locked") + + assert len(lock_opens) == (0 if when == "after_pins" else 1) + + @pytest.mark.timeout(30) + @requires_o_nofollow + @pytest.mark.parametrize("redirect", [".kiro", "settings"]) + def test_directory_swap_after_lock_check_never_creates_outside_lock( + self, tmp_path, monkeypatch, redirect + ): + from kiro_crew import workspace_cli_settings + + work_dir = tmp_path / "work" + work_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + if redirect == ".kiro": + (outside / "settings").mkdir() + else: + (work_dir / ".kiro").mkdir() + real_settings_dir = workspace_cli_settings._settings_dir_within_work_dir + checks = 0 + + def _swap_after_check(path): + nonlocal checks + settings_dir = real_settings_dir(path) + checks += 1 + if checks == 2: + try: + if redirect == ".kiro": + settings = work_dir / ".kiro" / "settings" + if settings.exists(): + settings.rmdir() + kiro_dir = work_dir / ".kiro" + if kiro_dir.exists(): + kiro_dir.rmdir() + platform_compat.symlink_or_junction(outside, kiro_dir) + else: + settings = work_dir / ".kiro" / "settings" + if settings.exists(): + settings.rmdir() + platform_compat.symlink_or_junction(outside, settings) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + return settings_dir + + before = _tree(outside) + monkeypatch.setattr( + workspace_cli_settings, "_settings_dir_within_work_dir", _swap_after_check + ) + + with pytest.raises(OSError, match="settings directory"): + with workspace_cli_settings_lock(work_dir): + pytest.fail("a swapped settings directory must not be locked") + + assert _tree(outside) == before + + @requires_o_nofollow + @pytest.mark.parametrize("swap", [".kiro", "settings", "removed"]) + def test_directory_swap_after_pinned_walk_never_creates_outside_lock( + self, tmp_path, monkeypatch, swap + ): + from kiro_crew import workspace_cli_settings + + work_dir = tmp_path / "work" + (work_dir / ".kiro" / "settings").mkdir(parents=True) + outside = tmp_path / "outside" + (outside / "settings").mkdir(parents=True) + real_pinned_settings_dir_fd = workspace_cli_settings._pinned_settings_dir_fd + + def _swap_after_walk(resolved_work_dir, settings_dir): + settings_fd = real_pinned_settings_dir_fd(resolved_work_dir, settings_dir) + try: + if swap == ".kiro": + (work_dir / ".kiro").rename(work_dir / ".kiro-real") + platform_compat.symlink_or_junction(outside, work_dir / ".kiro") + elif swap == "settings": + settings = work_dir / ".kiro" / "settings" + settings.rename(work_dir / ".kiro" / "settings-real") + platform_compat.symlink_or_junction(outside / "settings", settings) + else: + (work_dir / ".kiro" / "settings").rmdir() + except (OSError, NotImplementedError) as exc: + os.close(settings_fd) + pytest.fail(f"symlink is unavailable: {exc}") + return settings_fd + + before = _tree(outside) + monkeypatch.setattr(workspace_cli_settings, "_pinned_settings_dir_fd", _swap_after_walk) + + expected = "was removed" if swap == "removed" else "changed while it was opened" + with pytest.raises(OSError, match=expected): + with workspace_cli_settings_lock(work_dir): + pytest.fail("a swapped settings directory must not be locked") + + assert _tree(outside) == before + + @requires_o_nofollow + def test_pinned_lock_create_lost_to_a_sibling_opens_the_siblings_lock( + self, tmp_path, monkeypatch + ): + """A taker that loses the lock's create to a sibling holds the sibling's file. + + Darwin answers a nonexclusive ``O_CREAT`` open of a name two callers race to + create with ``ENOENT``; the settings lock is taken through the exclusive-then-reopen + helper, so a warm-pool fill and a chat start racing on one work dir both lock. + """ + import errno + + from kiro_crew import pinned_fs, platform_compat + + work_dir = tmp_path / "work" + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / CLI_SETTINGS_LOCK_NAME).write_bytes(b"") + real_open = os.open + racing_creates = [] + + def _darwin_racing_open(path, flags, mode=0o777, *, dir_fd=None): + if ( + os.fspath(path) == CLI_SETTINGS_LOCK_NAME + and flags & os.O_CREAT + and not flags & os.O_EXCL + ): + racing_creates.append(flags) + raise FileNotFoundError(errno.ENOENT, "No such file or directory", path) + return real_open(path, flags, mode, dir_fd=dir_fd) + + # ``supports_pinned_walk`` reads ``os.open in os.supports_dir_fd``, which the patched + # ``os.open`` fails, so the pinned branch is pinned on explicitly. + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: True) + monkeypatch.setattr(os, "open", _darwin_racing_open) + + with workspace_cli_settings_lock(work_dir) as cli_json: + assert cli_json == settings / "cli.json" + twin_fd = real_open(settings / CLI_SETTINGS_LOCK_NAME, os.O_RDWR) + try: + assert not platform_compat.try_acquire_lock(twin_fd, exclusive=True) + finally: + os.close(twin_fd) + + assert racing_creates == [] + + @requires_o_nofollow + def test_hardlinked_lock_twin_cannot_carry_a_swapped_settings_directory( + self, tmp_path, monkeypatch + ): + from kiro_crew import workspace_cli_settings + + work_dir = tmp_path / "work" + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / CLI_SETTINGS_LOCK_NAME).write_bytes(b"") + outside = tmp_path / "outside" + (outside / "settings").mkdir(parents=True) + try: + os.link( + settings / CLI_SETTINGS_LOCK_NAME, outside / "settings" / CLI_SETTINGS_LOCK_NAME + ) + except OSError as exc: + pytest.fail(f"hardlink is unavailable: {exc}") + real_pinned_settings_dir_fd = workspace_cli_settings._pinned_settings_dir_fd + + def _swap_after_walk(resolved_work_dir, settings_dir): + settings_fd = real_pinned_settings_dir_fd(resolved_work_dir, settings_dir) + try: + (work_dir / ".kiro").rename(work_dir / ".kiro-real") + platform_compat.symlink_or_junction(outside, work_dir / ".kiro") + except (OSError, NotImplementedError) as exc: + os.close(settings_fd) + pytest.fail(f"symlink is unavailable: {exc}") + return settings_fd + + before = _tree(outside) + monkeypatch.setattr(workspace_cli_settings, "_pinned_settings_dir_fd", _swap_after_walk) + + with pytest.raises(OSError, match="settings directory changed while it was opened"): + with workspace_cli_settings_lock(work_dir): + pytest.fail("a lock reached through a swapped settings directory is not held") + + assert _tree(outside) == before + + @pytest.mark.parametrize("redirect", [".kiro", "settings"]) + @pytest.mark.parametrize("floor", ["pinned", "by_name"]) + def test_hardlinked_lock_twin_swapped_in_while_acquiring_is_refused( + self, tmp_path, monkeypatch, floor, redirect + ): + from kiro_crew import pinned_fs, platform_compat + + work_dir = tmp_path / "work" + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / CLI_SETTINGS_LOCK_NAME).write_bytes(b"") + outside = tmp_path / "outside" + (outside / "settings").mkdir(parents=True) + os.link(settings / CLI_SETTINGS_LOCK_NAME, outside / "settings" / CLI_SETTINGS_LOCK_NAME) + moved = work_dir / ".kiro" if redirect == ".kiro" else settings + target = outside if redirect == ".kiro" else outside / "settings" + real_file_lock = platform_compat.file_lock + swap_blocked = False + + @contextmanager + def _swap_then_lock(fd, **kwargs): + nonlocal swap_blocked + try: + moved.rename(moved.with_name(moved.name + "-real")) + except PermissionError: + swap_blocked = True + else: + platform_compat.symlink_or_junction(target, moved) + with real_file_lock(fd, **kwargs): + yield + + before = _tree(outside) + if floor == "by_name": + monkeypatch.setattr(pinned_fs, "supports_pinned_walk", lambda: False) + monkeypatch.setattr(platform_compat, "file_lock", _swap_then_lock) + + failure = None + try: + with workspace_cli_settings_lock(work_dir): + pass + except OSError as exc: + failure = exc + + if swap_blocked: + assert failure is None + else: + assert failure is not None + assert "settings directory changed while it was acquired" in str(failure) + assert _tree(outside) == before + + @requires_o_nofollow + @pytest.mark.parametrize("writer", ["effort", "tool_search", "clear"]) + @pytest.mark.parametrize("swap", ["link", "directory"]) + def test_settings_swap_after_read_never_redirects_publish( + self, tmp_path, monkeypatch, writer, swap + ): + from kiro_crew.providers.acp import _write_tool_search_overlay + + work_dir = tmp_path / "work" + settings_dir = work_dir / ".kiro" / "settings" + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + held_settings_dir = work_dir / ".kiro" / "settings-held" + replacement = tmp_path / "outside" if swap == "link" else settings_dir + if swap == "link": + replacement.mkdir() + replacement_cli_json = replacement / "cli.json" + sentinel = b'{"token": "SECRET"}' + if swap == "link": + replacement_cli_json.write_bytes(sentinel) + before_names = {path.name for path in replacement.iterdir()} + else: + before_names = {"cli.json"} + held_before = (settings_dir / "cli.json").read_bytes() + real_read = acp_provider._read_cli_overlay_document + + def _swap_after_read(settings): + document = real_read(settings) + settings_dir.rename(held_settings_dir) + try: + if swap == "link": + platform_compat.symlink_or_junction(replacement, settings_dir) + else: + settings_dir.mkdir() + except (OSError, NotImplementedError) as exc: + pytest.fail(f"settings replacement is unavailable: {exc}") + if swap == "directory": + replacement_cli_json.write_bytes(sentinel) + return document + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "_read_cli_overlay_document", _swap_after_read) + if writer == "effort": + with pytest.raises(OSError, match="workspace cli.json overlay|settings directory"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "low") + elif writer == "tool_search": + with pytest.raises(OSError, match="workspace cli.json overlay|settings directory"): + _write_tool_search_overlay(work_dir, True) + else: + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + assert replacement_cli_json.read_bytes() == sentinel + assert {path.name for path in replacement.iterdir()} == before_names + assert (held_settings_dir / "cli.json").read_bytes() == held_before + + @requires_o_nofollow + @pytest.mark.parametrize("writer", ["effort", "tool_search"]) + @pytest.mark.parametrize("path", ["pinned", "by-name"]) + @pytest.mark.parametrize("outside_write", ["replace", "in-place"]) + def test_a_projection_that_writes_nothing_still_refuses_a_cli_json_changed_after_read( + self, tmp_path, monkeypatch, writer, path, outside_write + ): + work_dir = tmp_path / "work" + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + if writer == "tool_search": + _write_tool_search_overlay(work_dir, True) + cli_json = work_dir / ".kiro" / "settings" / "cli.json" + outside_bytes = b'{"operator.sentinel": true, "padding": "outside-write"}' + outside_write_landed = False + real_read = acp_provider._read_cli_overlay_document + + def _outside_write_after_read(settings): + nonlocal outside_write_landed + document = real_read(settings) + outside_write_landed = True + if outside_write == "replace": + replacement = cli_json.with_name("replacement.json") + replacement.write_bytes(outside_bytes) + os.replace(replacement, cli_json) + else: + cli_json.write_bytes(outside_bytes) + return document + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr( + acp_provider.atomic_write_module, + "pinned_parent_replace_supported", + lambda: path == "pinned", + ) + temporary_patches.setattr( + acp_provider, "_read_cli_overlay_document", _outside_write_after_read + ) + if writer == "effort": + with pytest.raises(OSError, match="changed while the overlay was written"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + else: + with pytest.raises(OSError, match="changed while the overlay was written"): + _write_tool_search_overlay(work_dir, True) + + assert outside_write_landed + assert cli_json.read_bytes() == outside_bytes + + @requires_o_nofollow + @pytest.mark.parametrize("writer", ["effort", "tool_search", "clear"]) + @pytest.mark.parametrize("path", ["pinned", "by-name"]) + @pytest.mark.parametrize("outside_write", ["replace", "in-place"]) + def test_cli_json_changed_after_read_is_not_overwritten( + self, tmp_path, monkeypatch, writer, path, outside_write + ): + work_dir = tmp_path / "work" + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + cli_json = work_dir / ".kiro" / "settings" / "cli.json" + outside_bytes = b'{"operator.sentinel": true, "padding": "outside-write"}' + real_read = acp_provider._read_cli_overlay_document + + def _outside_write_after_read(settings): + document = real_read(settings) + if outside_write == "replace": + replacement = cli_json.with_name("replacement.json") + replacement.write_bytes(outside_bytes) + os.replace(replacement, cli_json) + else: + cli_json.write_bytes(outside_bytes) + return document + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr( + acp_provider.atomic_write_module, + "pinned_parent_replace_supported", + lambda: path == "pinned", + ) + temporary_patches.setattr( + acp_provider, "_read_cli_overlay_document", _outside_write_after_read + ) + if writer == "effort": + with pytest.raises(OSError, match="changed while the overlay was written"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "low") + elif writer == "tool_search": + with pytest.raises(OSError, match="changed while the overlay was written"): + _write_tool_search_overlay(work_dir, True) + else: + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + assert cli_json.read_bytes() == outside_bytes + + @requires_o_nofollow + @pytest.mark.parametrize("writer", ["effort", "tool_search", "clear"]) + @pytest.mark.parametrize("path", ["pinned", "by-name"]) + @pytest.mark.parametrize("outside_write", ["replace", "in-place"]) + def test_cli_json_changed_just_after_its_bytes_are_read_is_not_overwritten( + self, tmp_path, monkeypatch, writer, path, outside_write + ): + work_dir = tmp_path / "work" + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + cli_json = work_dir / ".kiro" / "settings" / "cli.json" + read_file = os.stat(cli_json, follow_symlinks=False) + outside_bytes = b'{"operator.sentinel": true, "padding": "outside-write"}' + outside_write_landed = False + + def _write_outside(): + nonlocal outside_write_landed + outside_write_landed = True + if outside_write == "replace": + replacement = cli_json.with_name("replacement.json") + replacement.write_bytes(outside_bytes) + os.replace(replacement, cli_json) + else: + cli_json.write_bytes(outside_bytes) + + real_fstat = os.fstat + real_read_by_name = acp_provider.safe_read_file_bytes_nolink + + def _fstat_writing_outside_once_the_bytes_are_read(fd, *args, **kwargs): + if not outside_write_landed: + metadata = real_fstat(fd) + same_file = (metadata.st_dev, metadata.st_ino) == ( + read_file.st_dev, + read_file.st_ino, + ) + # Only the pinned reader's fstat after its read finds the offset past 0. + if same_file and os.lseek(fd, 0, os.SEEK_CUR) > 0: + _write_outside() + return real_fstat(fd, *args, **kwargs) + + def _read_by_name_then_write_outside(*args, **kwargs): + raw = real_read_by_name(*args, **kwargs) + _write_outside() + return raw + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr( + acp_provider.atomic_write_module, + "pinned_parent_replace_supported", + lambda: path == "pinned", + ) + if path == "pinned": + temporary_patches.setattr( + acp_provider.os, "fstat", _fstat_writing_outside_once_the_bytes_are_read + ) + else: + temporary_patches.setattr( + acp_provider, "safe_read_file_bytes_nolink", _read_by_name_then_write_outside + ) + if writer == "effort": + with pytest.raises(OSError, match="changed while the overlay was written"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "low") + elif writer == "tool_search": + with pytest.raises(OSError, match="changed while the overlay was written"): + _write_tool_search_overlay(work_dir, True) + else: + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + assert outside_write_landed + assert cli_json.read_bytes() == outside_bytes + + @requires_o_nofollow + @pytest.mark.parametrize("writer", ["effort", "tool_search", "clear"]) + def test_settings_directory_replaced_after_the_publish_check_keeps_its_cli_json( + self, tmp_path, monkeypatch, writer + ): + from kiro_crew.providers.acp import _write_tool_search_overlay + from kiro_crew.workspace_cli_settings import LockedCliSettings + + work_dir = tmp_path / "work" + settings_dir = work_dir / ".kiro" / "settings" + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + held_settings_dir = work_dir / ".kiro" / "settings-held" + replacement_cli_json = settings_dir / "cli.json" + sentinel = b'{"operator.sentinel": true}' + real_check = LockedCliSettings.holds_named_settings_dir + checks = 0 + + def _replace_after_first_check(settings): + nonlocal checks + holds = real_check(settings) + checks += 1 + if checks == 1: + settings_dir.rename(held_settings_dir) + settings_dir.mkdir() + replacement_cli_json.write_bytes(sentinel) + return holds + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr( + LockedCliSettings, "holds_named_settings_dir", _replace_after_first_check + ) + if writer == "effort": + with pytest.raises(OSError, match="settings directory changed while"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "low") + elif writer == "tool_search": + with pytest.raises(OSError, match="settings directory changed while"): + _write_tool_search_overlay(work_dir, True) + else: + assert _clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + + assert checks == 2 + assert replacement_cli_json.read_bytes() == sentinel + assert {path.name for path in settings_dir.iterdir()} == {"cli.json"} + + @requires_o_nofollow + def test_settings_directory_replacement_before_read_cannot_receive_publish( + self, tmp_path, monkeypatch + ): + + work_dir = tmp_path / "work" + settings_dir = work_dir / ".kiro" / "settings" + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + held_settings_dir = work_dir / ".kiro" / "settings-held" + replacement_cli_json = settings_dir / "cli.json" + sentinel = ( + b'{"operator.sentinel": true, "chat.modelDefaults": {"claude-opus-4.7": ' + b'{"output_config": {"effort": "max"}}}}' + ) + real_read = acp_provider._read_cli_overlay_document + documents_read = [] + + def _replace_before_read(settings): + settings_dir.rename(held_settings_dir) + settings_dir.mkdir() + replacement_cli_json.write_bytes(sentinel) + document = real_read(settings) + documents_read.append(document) + return document + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr( + acp_provider, "_read_cli_overlay_document", _replace_before_read + ) + with pytest.raises(OSError, match="settings directory"): + _write_cli_overlay(work_dir, "claude-opus-4.7", "low") + + assert documents_read and "operator.sentinel" not in documents_read[0] + assert replacement_cli_json.read_bytes() == sentinel + assert {path.name for path in settings_dir.iterdir()} == {"cli.json"} + + @pytest.mark.timeout(30) + @pytest.mark.parametrize("kind", ["fifo", "directory"]) + def test_non_regular_cli_json_is_refused_without_blocking_or_leaking( + self, tmp_path, monkeypatch, kind + ): + import stat + + from kiro_crew.providers.acp import _write_tool_search_overlay + + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + simulated_fifo = kind == "fifo" and not hasattr(os, "mkfifo") + if kind == "fifo" and not simulated_fifo: + os.mkfifo(cli_json) + elif kind == "fifo": + cli_json.write_bytes(b"{}") + cli_json_stat = os.stat(cli_json) + real_fstat = os.fstat + + def _fstat_as_fifo(fd): + info = real_fstat(fd) + if (info.st_dev, info.st_ino) == (cli_json_stat.st_dev, cli_json_stat.st_ino): + return os.stat_result( + ( + stat.S_IFIFO | (info.st_mode & 0o7777), + *info[1 : os.stat_result.n_sequence_fields], + ), + {name: getattr(info, name) for name in dir(info) if name.startswith("st_")}, + ) + return info + + monkeypatch.setattr(os, "fstat", _fstat_as_fifo) + else: + cli_json.mkdir() + open_descriptors = Path("/proc/self/fd") + + def _refused_round(): + with pytest.raises(OSError, match="workspace cli.json overlay"): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="workspace cli.json overlay"): + _write_tool_search_overlay(tmp_path, True) + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False + + _refused_round() + open_before = len(os.listdir(open_descriptors)) if open_descriptors.is_dir() else None + + for _ in range(5): + _refused_round() + + if open_before is not None: + assert len(os.listdir(open_descriptors)) == open_before + if kind == "fifo" and not simulated_fifo: + assert cli_json.is_fifo() + elif kind == "fifo": + assert cli_json.is_file() + else: + assert cli_json.is_dir() + + @requires_symlinks + def test_leaf_link_inside_settings_is_never_read_or_replaced(self, tmp_path): + from kiro_crew.providers.acp import _write_tool_search_overlay + + settings_dir = tmp_path / ".kiro" / "settings" + settings_dir.mkdir(parents=True) + cli_json = settings_dir / "cli.json" + other_json = settings_dir / "other.json" + secret = b'{"token": "SECRET"}' + other_json.write_bytes(secret) + try: + cli_json.symlink_to(other_json) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + + with pytest.raises(OSError, match="is a link"): + _write_cli_overlay(tmp_path, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="is a link"): + _write_tool_search_overlay(tmp_path, True) + assert _clear_cli_overlay_effort(tmp_path, "claude-opus-4.7") is False + assert cli_json.is_symlink() + assert other_json.read_bytes() == secret diff --git a/test/test_effort_overlay_readers.py b/test/test_effort_overlay_readers.py index 17e38ce873e..05b8c8784d0 100644 --- a/test/test_effort_overlay_readers.py +++ b/test/test_effort_overlay_readers.py @@ -16,10 +16,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from effort_helpers import _read_cli_overlay from kiro_crew.acp.types import ACP_BACKEND_KAS, ACP_BACKEND_KIRO from kiro_crew.providers.acp import AcpProvider -from kiro_crew.providers.acp_cli_overlay import _read_cli_overlay, _write_cli_overlay +from kiro_crew.providers.acp_cli_overlay import _write_cli_overlay MODEL = "claude-opus-5.5" LEVELS = ["low", "medium", "high", "xhigh", "max"] @@ -113,15 +114,19 @@ async def test_clearing_a_kas_level_asks_for_a_reset_and_leaves_the_file(self, t assert _read_cli_overlay(tmp_path) == {MODEL: "high"} -class TestKiroCliStillMirrorsTheFileItReads: - def test_kiro_cli_reads_the_level_back(self, tmp_path): - # kiro-cli applies the file's level at spawn, so the map names that level. +class TestKiroCliTakesOnlyItsOwnLevel: + def test_kiro_cli_does_not_read_another_chats_level_back(self, tmp_path): + # The file is shared by the work dir, so its level is no chat's own pick. _write_cli_overlay(tmp_path, MODEL, "max") provider = _provider(tmp_path, ACP_BACKEND_KIRO) - assert provider._effort_per_model == {MODEL: "max"} - assert provider._resolve_effort() == "max" + assert provider._effort_per_model == {} + assert provider._resolve_effort() is None - def test_kiro_cli_writes_its_own_level(self, tmp_path): - _provider(tmp_path, ACP_BACKEND_KIRO, effort_per_model={MODEL: "low"}) + def test_kiro_cli_writes_its_own_level_before_its_spawn(self, tmp_path): + provider = _provider(tmp_path, ACP_BACKEND_KIRO, effort_per_model={MODEL: "low"}) + # Construction does no file I/O; the projection before each spawn writes. + assert not _overlay(tmp_path).exists() + + assert provider._apply_effort_overlay() is True assert _read_cli_overlay(tmp_path) == {MODEL: "low"} diff --git a/test/test_effort_shared_overlay.py b/test/test_effort_shared_overlay.py new file mode 100644 index 00000000000..3aa5636aa4e --- /dev/null +++ b/test/test_effort_shared_overlay.py @@ -0,0 +1,1592 @@ +"""cli.json overlay tests: one file per work dir, shared by every session there. + +A session's own pick is never read back from the shared overlay, a picked level +survives a peer's clear, and the async effort paths keep work-directory +filesystem calls off the loop. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import threading +from contextlib import contextmanager, suppress +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest +from effort_helpers import _UNPARSEABLE_OVERLAY_CONTENTS, _read_cli_overlay +from test_effort import _await_test + +from conftest import requires_symlinks +from kiro_crew.acp.types import ACP_BACKEND_KIRO +from kiro_crew.effort import effort_settings_key +from kiro_crew.providers import acp as acp_provider +from kiro_crew.providers.acp import ( + _KIROCREW_EFFORT_OWNED_KEY, + _clear_cli_overlay_effort, + _write_cli_overlay, +) +from kiro_crew.testing.wait import async_wait_until +from kiro_crew.workspace_cli_settings import ( + locked_workspace_cli_settings, + workspace_cli_settings_lock, +) + +pytestmark = pytest.mark.usefixtures("no_zero_tool_check") + + +@contextmanager +def _record_work_dir_io(monkeypatch, work_dir: Path): + """Record watched work-directory I/O with the calling thread identity.""" + absolute_path = os.path.abspath + normalized_roots = { + os.path.normcase(absolute_path(work_dir)), + os.path.normcase(os.path.realpath(work_dir)), + } + calls: list[tuple[str, int]] = [] + + def _under_root(arg: object) -> bool: + try: + text = os.fspath(arg) + except TypeError: + return False + if isinstance(text, bytes): + text = text.decode("utf-8", "replace") + candidate = os.path.normcase(absolute_path(text)) + return any( + candidate == root or candidate.startswith(root + os.path.sep) + for root in normalized_roots + ) + + def _probe(name: str, original): + def probe(path, *args, **kwargs): + if kwargs.get("dir_fd") is not None or _under_root(path): + calls.append((name, threading.get_ident())) + return original(path, *args, **kwargs) + + return probe + + monkeypatch.setattr(os.path, "realpath", _probe("realpath", os.path.realpath)) + monkeypatch.setattr(os, "stat", _probe("stat", os.stat)) + monkeypatch.setattr(os, "lstat", _probe("lstat", os.lstat)) + monkeypatch.setattr(os, "open", _probe("open", os.open)) + monkeypatch.setattr(os, "listdir", _probe("listdir", os.listdir)) + monkeypatch.setattr(os, "mkdir", _probe("mkdir", os.mkdir)) + yield calls + + +class TestTheSharedOverlayIsNotReadBack: + """The overlay is one file per WORK DIR, shared by every session there. + + A level in it may be another session's pick, so a provider never adopts it. + It projects its own resolved level before each spawn instead: the level is + written, or, when none resolves, the model's entry is removed so the model + runs at its own default. These build a real kiro provider on a temp work dir + and invoke the same projection start() runs before a spawn. + """ + + _MODEL = "claude-opus-4.7" + + def _provider(self, work_dir, *, model=None, **kwargs): + return acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=work_dir, + model=model or self._MODEL, + **kwargs, + ) + + @staticmethod + def _write_operator_effort(work_dir, model, level): + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True, exist_ok=True) + cli_json = settings / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) if cli_json.exists() else {} + model_defaults = data.setdefault("chat.modelDefaults", {}) + model_cfg = model_defaults.setdefault(model, {}) + model_cfg[effort_settings_key(model)] = {"effort": level} + cli_json.write_text(json.dumps(data), encoding="utf-8") + + @staticmethod + def _owned_effort(work_dir): + path = work_dir / ".kiro" / "settings" / "cli.json" + if not path.exists(): + return {} + data = json.loads(path.read_text(encoding="utf-8")) + record = data.get(_KIROCREW_EFFORT_OWNED_KEY) + return record if isinstance(record, dict) else {} + + @pytest.mark.asyncio + @pytest.mark.parametrize("model", [_MODEL, "auto"]) + async def test_operator_authored_effort_survives_default_construction_and_start( + self, tmp_path, monkeypatch, model + ): + operator_model = self._MODEL if model == "auto" else model + self._write_operator_effort(tmp_path, operator_model, "max") + + provider = self._provider(tmp_path, model=model) + assert _read_cli_overlay(tmp_path) == {operator_model: "max"} + + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + await provider.start() + + assert _read_cli_overlay(tmp_path) == {operator_model: "max"} + assert self._owned_effort(tmp_path) == {} + + @staticmethod + def _write_unparseable_overlay(work_dir, contents): + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True, exist_ok=True) + cli_json = settings / "cli.json" + cli_json.write_bytes(contents) + return cli_json + + @staticmethod + def _write_undecodable_overlay(work_dir): + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True, exist_ok=True) + cli_json = settings / "cli.json" + contents = b'{"x": "\xff"}' + cli_json.write_bytes(contents) + return cli_json, contents + + @classmethod + def _write_oversized_overlay(cls, work_dir): + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True, exist_ok=True) + cli_json = settings / "cli.json" + contents = json.dumps( + {"chat.modelDefaults": {cls._MODEL: {"output_config": {"effort": "max"}}}} + ).encode("utf-8") + ceiling = acp_provider._CLI_JSON_MAX_BYTES + contents += b" " * (ceiling + 1 - len(contents)) + cli_json.write_bytes(contents) + return cli_json, contents + + @_UNPARSEABLE_OVERLAY_CONTENTS + @pytest.mark.parametrize("model", [_MODEL, "auto"]) + def test_a_default_projection_ignores_a_file_no_parser_can_read( + self, tmp_path, caplog, model, contents + ): + cli_json = self._write_unparseable_overlay(tmp_path, contents) + provider = self._provider(tmp_path, model=model) + + warnings = self._pre_spawn_warnings(caplog, provider) + + assert warnings == [] + assert cli_json.read_bytes() == contents + + @_UNPARSEABLE_OVERLAY_CONTENTS + @pytest.mark.asyncio + @pytest.mark.parametrize("model", [_MODEL, "auto"]) + async def test_a_start_survives_a_file_no_parser_can_read( + self, tmp_path, monkeypatch, model, contents + ): + cli_json = self._write_unparseable_overlay(tmp_path, contents) + provider = self._provider(tmp_path, model=model) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + + await provider.start() + + assert cli_json.read_bytes() == contents + provider._start_kiro_runtime.assert_awaited_once() + + @pytest.mark.asyncio + @pytest.mark.parametrize("model", [_MODEL, "auto"]) + async def test_an_undecodable_overlay_does_not_break_a_default_session( + self, tmp_path, monkeypatch, model + ): + # The pre-spawn removal runs from start() for every Default and auto + # session in the work dir: a file kiro-cli cannot read either must not + # take start down with it, and its bytes are the operator's, so they stay. + cli_json, contents = self._write_undecodable_overlay(tmp_path) + + provider = self._provider(tmp_path, model=model) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + await provider.start() + + assert cli_json.read_bytes() == contents + + @pytest.mark.asyncio + async def test_projecting_a_level_onto_an_undecodable_overlay_fails_closed( + self, tmp_path, monkeypatch, caplog + ): + # The write cannot merge into the file and must not reset it, so the + # projection reports failure rather than a level the file does not hold; + # construction and start still succeed, and the live push at spawn is + # what carries the level. + cli_json, contents = self._write_undecodable_overlay(tmp_path) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + provider = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + assert provider._apply_effort_overlay() is False + assert cli_json.read_bytes() == contents + assert any("overlay write failed" in r.getMessage() for r in caplog.records) + + provider._client.send_command = AsyncMock(return_value="") + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + await provider.start() + + assert cli_json.read_bytes() == contents + provider._client.send_command.assert_awaited_once_with("/effort", args={"level": "max"}) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "contents", [b"{ not json", b'{"x": "\xff"}'], ids=["malformed", "undecodable"] + ) + async def test_change_effort_refuses_to_push_over_an_unmergeable_overlay( + self, tmp_path, contents + ): + # change_effort pushes live only once the file holds the level. A file + # the write cannot merge into is left as the operator wrote it, the + # override is rolled back, and the caller is told. + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_bytes(contents) + provider = self._provider(tmp_path) + provider._client.send_command = AsyncMock(return_value="") + + with pytest.raises(RuntimeError, match="could not persist effort"): + await provider.change_effort("max") + + assert cli_json.read_bytes() == contents + assert self._MODEL not in provider._effort_per_model + provider._client.send_command.assert_not_awaited() + + @pytest.mark.asyncio + async def test_change_effort_reports_an_oversized_overlay_and_leaves_it_unchanged( + self, tmp_path + ): + cli_json, contents = self._write_oversized_overlay(tmp_path) + provider = self._provider(tmp_path) + provider._client.send_command = AsyncMock(return_value="") + + with pytest.raises(RuntimeError, match="read ceiling"): + await provider.change_effort("max") + + assert cli_json.read_bytes() == contents + assert self._MODEL not in provider._effort_per_model + provider._client.send_command.assert_not_awaited() + + @pytest.mark.asyncio + @requires_symlinks + async def test_change_effort_names_a_linked_overlay_and_leaves_it_unchanged(self, tmp_path): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + shared_overlay = tmp_path / "shared-cli.json" + contents = b'{"chat.modelDefaults": {}}' + shared_overlay.write_bytes(contents) + cli_json = settings / "cli.json" + try: + cli_json.symlink_to(shared_overlay) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + provider = self._provider(tmp_path) + provider._client.send_command = AsyncMock(return_value="") + + with pytest.raises(RuntimeError, match="a link or in a folder outside the work dir"): + await provider.change_effort("max") + + assert cli_json.is_symlink() + assert shared_overlay.read_bytes() == contents + assert self._MODEL not in provider._effort_per_model + provider._client.send_command.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_failed_live_push_restores_the_prior_level_past_a_launch_length_hold( + self, tmp_path, caplog + ): + # The rollback that restores the prior level waits the ACTION ceiling, + # as the write it undoes did. Under the startup ceiling a holder that + # outlasts a launch's wait leaves the file at the level the live push + # never applied, and the next spawn adopts it. + provider = self._provider(tmp_path, effort_per_model={self._MODEL: "low"}) + assert provider._apply_effort_overlay() is True + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + hold_secs = acp_provider.CLI_SETTINGS_LOCK_TIMEOUT_SECS + 0.5 + assert hold_secs < acp_provider.CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS / 4 + arm = threading.Event() + held = threading.Event() + release = threading.Event() + + def _hold_the_lock_past_a_launch_wait(): + if not arm.wait(timeout=5): + return + with workspace_cli_settings_lock(tmp_path): + held.set() + release.wait(timeout=acp_provider.CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS) + + async def _refuse_once_the_lock_is_held(*_args, **_kwargs): + # The first write has landed by now; the holder takes the lock + # before the rollback's write asks for it. + arm.set() + await async_wait_until(held.is_set, describe=lambda: "held was never set") + releaser.start() + raise RuntimeError("refused") + + provider._client.send_command = AsyncMock(side_effect=_refuse_once_the_lock_is_held) + holder = threading.Thread(target=_hold_the_lock_past_a_launch_wait) + releaser = threading.Timer(hold_secs, release.set) + try: + holder.start() + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + with pytest.raises(RuntimeError, match="refused"): + await provider.change_effort("max") + finally: + arm.set() + release.set() + releaser.cancel() + holder.join(timeout=5) + assert not holder.is_alive() + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + assert provider._effort_per_model[self._MODEL] == "low" + messages = [r.getMessage() for r in caplog.records] + assert not any("rollback left the overlay set" in m for m in messages) + assert any("live push failed" in m for m in messages) + + def test_non_ascii_overlay_that_fits_in_utf8_is_published_and_readable(self, tmp_path): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + note = "界" * 180_000 + initial = {"operator.note": note} + cli_json.write_bytes(json.dumps(initial, ensure_ascii=False).encode("utf-8")) + + assert cli_json.stat().st_size < acp_provider._CLI_JSON_MAX_BYTES + assert len(json.dumps(initial, indent=2).encode("utf-8")) > acp_provider._CLI_JSON_MAX_BYTES + + _write_cli_overlay(tmp_path, self._MODEL, "max") + + assert cli_json.stat().st_size <= acp_provider._CLI_JSON_MAX_BYTES + with locked_workspace_cli_settings(tmp_path) as locked_settings: + document, _, _ = acp_provider._read_cli_overlay_document(locked_settings) + assert document["operator.note"] == note + assert document["chat.modelDefaults"][self._MODEL]["output_config"]["effort"] == "max" + + def test_publish_refuses_output_over_the_ceiling_without_replacing_the_file( + self, tmp_path, monkeypatch + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + contents = json.dumps({"padding": [0] * 50}, separators=(",", ":")).encode("utf-8") + cli_json.write_bytes(contents) + monkeypatch.setattr(acp_provider, "_CLI_JSON_MAX_BYTES", 500) + assert len(contents) <= acp_provider._CLI_JSON_MAX_BYTES + + with pytest.raises( + acp_provider._OverlayTooLarge, match="would exceed the 500-byte ceiling" + ): + _write_cli_overlay(tmp_path, self._MODEL, "max") + + assert cli_json.read_bytes() == contents + + def test_clear_returns_false_when_its_publication_would_exceed_the_ceiling( + self, tmp_path, monkeypatch, caplog + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + stamp = 1_700_000_000 + document = { + "padding": [0] * 200, + "chat.modelDefaults": {self._MODEL: {"output_config": {"effort": "max"}}}, + _KIROCREW_EFFORT_OWNED_KEY: {self._MODEL: "max"}, + acp_provider._KIROCREW_EFFORT_OWNED_STAMP_KEY: stamp, + } + contents = json.dumps(document, separators=(",", ":")).encode("utf-8") + cli_json.write_bytes(contents) + os.utime(cli_json, ns=(stamp * 1_000_000_000, stamp * 1_000_000_000)) + monkeypatch.setattr(acp_provider, "_CLI_JSON_MAX_BYTES", len(contents)) + cleared = json.dumps({"padding": [0] * 200}, indent=2, ensure_ascii=False).encode("utf-8") + assert len(cleared) > len(contents) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert _clear_cli_overlay_effort(tmp_path, self._MODEL) is False + + assert cli_json.read_bytes() == contents + assert any(str(len(contents)) in record.getMessage() for record in caplog.records) + + def test_an_unpaired_surrogate_is_written_escaped_and_still_clears(self, tmp_path): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text('{"operator.note": "\\ud800"}', encoding="utf-8") + + _write_cli_overlay(tmp_path, self._MODEL, "max") + + written = cli_json.read_bytes() + assert b"\\ud800" in written + document = json.loads(written) + assert document["operator.note"] == "\ud800" + assert document["chat.modelDefaults"][self._MODEL]["output_config"]["effort"] == "max" + assert _clear_cli_overlay_effort(tmp_path, self._MODEL) is True + assert json.loads(cli_json.read_bytes()) == {"operator.note": "\ud800"} + + def test_serialized_overlay_at_the_exact_ceiling_is_accepted(self, tmp_path, monkeypatch): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_bytes(b"{}") + document = {"operator.note": "界\nvalue"} + serialized = json.dumps(document, indent=2, ensure_ascii=False).encode("utf-8") + monkeypatch.setattr(acp_provider, "_CLI_JSON_MAX_BYTES", len(serialized)) + + with locked_workspace_cli_settings(tmp_path) as locked_settings: + read_document, file_mtime, file_version = acp_provider._read_cli_overlay_document( + locked_settings + ) + acp_provider._publish_cli_overlay( + locked_settings, + document, + read_document, + file_mtime, + file_version, + ) + + assert cli_json.read_bytes() == serialized + with locked_workspace_cli_settings(tmp_path) as locked_settings: + read_back, _, _ = acp_provider._read_cli_overlay_document(locked_settings) + assert read_back == document + + def test_an_oversized_overlay_write_is_refused_and_leaves_it_unchanged(self, tmp_path): + cli_json, contents = self._write_oversized_overlay(tmp_path) + + with pytest.raises(ValueError, match="left unchanged"): + _write_cli_overlay(tmp_path, self._MODEL, "low") + + assert cli_json.read_bytes() == contents + + @pytest.mark.asyncio + async def test_clear_effort_reports_an_oversized_overlay_and_leaves_it_unchanged( + self, tmp_path, caplog + ): + cli_json, contents = self._write_oversized_overlay(tmp_path) + provider = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert await provider.clear_effort(owned_only=False) is None + + assert cli_json.read_bytes() == contents + assert provider._effort_per_model[self._MODEL] == "max" + assert any("read ceiling" in record.getMessage() for record in caplog.records) + + def test_a_clear_refuses_an_oversized_overlay(self, tmp_path, caplog): + cli_json, contents = self._write_oversized_overlay(tmp_path) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert _clear_cli_overlay_effort(tmp_path, None, warn_unowned=True) is False + + assert cli_json.read_bytes() == contents + assert any( + str(acp_provider._CLI_JSON_MAX_BYTES) in record.getMessage() + for record in caplog.records + ) + + def test_a_default_spawn_warns_when_the_overlay_is_too_large_to_read(self, tmp_path, caplog): + self._write_oversized_overlay(tmp_path) + provider = self._provider(tmp_path, model="auto") + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert provider._apply_effort_overlay() is False + + assert any( + "may run a level another session projected" in record.getMessage() + for record in caplog.records + ) + + def test_a_warning_sample_retains_a_bounded_number_of_bounded_pairs(self): + pairs = acp_provider._BoundedCliEffortPairs() + for index in range(500): + pairs.add(f"model-{index}-" + "m" * 100_000, "e" * 100_000) + + assert pairs.sample_size == acp_provider._WARN_UNOWNED_MAX_ITEMS + assert pairs.omitted == 500 - acp_provider._WARN_UNOWNED_MAX_ITEMS + sample, suffix = pairs.render().rsplit(", ... ", 1) + assert all( + len(token) <= 2 * acp_provider._WARN_UNOWNED_MAX_CHARS + 1 + for token in sample.split(", ") + ) + assert suffix == f"{pairs.omitted} more" + + def test_both_effort_log_populations_share_one_bounded_collector(self, tmp_path, monkeypatch): + collector_type = acp_provider._BoundedCliEffortPairs + constructions = [] + + class _RecordingPairs(collector_type): + def __init__(self): + constructions.append(self) + super().__init__() + + monkeypatch.setattr(acp_provider, "_BoundedCliEffortPairs", _RecordingPairs) + acp_provider._warn_unowned_cli_effort( + tmp_path / ".kiro" / "settings" / "cli.json", + {self._MODEL: {"output_config": {"effort": "max"}}}, + self._MODEL, + ) + _write_cli_overlay(tmp_path, self._MODEL, "max") + assert _clear_cli_overlay_effort(tmp_path, self._MODEL) is True + + assert len(constructions) == 2 + + def test_a_level_kiro_crew_left_is_not_adopted(self, tmp_path): + _write_cli_overlay(tmp_path, self._MODEL, "max") + assert self._owned_effort(tmp_path) == {self._MODEL: "max"} + + provider = self._provider(tmp_path) + assert provider._apply_effort_overlay() is True + + assert provider._resolve_effort() is None + assert _read_cli_overlay(tmp_path) == {} + assert self._owned_effort(tmp_path) == {} + + @pytest.mark.parametrize("model", ["claude-opus-4.7", "auto"], ids=["default-concrete", "auto"]) + def test_construction_leaves_owned_overlay_unchanged_until_pre_spawn_projection( + self, tmp_path, model + ): + _write_cli_overlay(tmp_path, self._MODEL, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + + provider = self._provider(tmp_path, model=model) + + assert cli_json.read_bytes() == before + assert provider._apply_effort_overlay() is True + assert _read_cli_overlay(tmp_path) == {} + assert self._owned_effort(tmp_path) == {} + + def test_malformed_ownership_record_owns_nothing(self, tmp_path): + self._write_operator_effort(tmp_path, self._MODEL, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + data[_KIROCREW_EFFORT_OWNED_KEY] = "not a map" + cli_json.write_text(json.dumps(data), encoding="utf-8") + + provider = self._provider(tmp_path) + assert provider._apply_effort_overlay() is True + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + assert self._owned_effort(tmp_path) == {} + + def test_operator_change_after_kiro_crew_write_survives_and_drops_ownership(self, tmp_path): + _write_cli_overlay(tmp_path, self._MODEL, "max") + self._write_operator_effort(tmp_path, self._MODEL, "low") + + provider = self._provider(tmp_path) + assert provider._apply_effort_overlay() is True + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + assert self._owned_effort(tmp_path) == {} + + def test_removed_owned_entry_cannot_authorize_a_later_operator_value(self, tmp_path): + _write_cli_overlay(tmp_path, self._MODEL, "max") + assert self._owned_effort(tmp_path) == {self._MODEL: "max"} + + self._write_operator_effort(tmp_path, self._MODEL, "low") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + data["chat.modelDefaults"].pop(self._MODEL) + cli_json.write_text(json.dumps(data), encoding="utf-8") + + provider = self._provider(tmp_path) + assert provider._apply_effort_overlay() is True + assert self._owned_effort(tmp_path) == {} + + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider(tmp_path) + assert provider._apply_effort_overlay() is True + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + assert self._owned_effort(tmp_path) == {} + + def test_levels_for_other_models_are_neither_adopted_nor_removed(self, tmp_path): + # A live model switch reads this map, so an adopted entry would carry + # another session's pick onto the model switched to. + _write_cli_overlay(tmp_path, "gpt-5.6-sol", "max") + + provider = self._provider(tmp_path) + + assert provider._effort_per_model == {} + assert _read_cli_overlay(tmp_path) == {"gpt-5.6-sol": "max"} + + def test_this_sessions_own_level_replaces_the_leftover(self, tmp_path): + _write_cli_overlay(tmp_path, self._MODEL, "max") + + provider = self._provider(tmp_path, effort_per_model={self._MODEL: "low"}) + assert provider._apply_effort_overlay() is True + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + + def test_every_spawn_projects_again(self, tmp_path): + # The file can change between construction and a later (re)spawn. + provider = self._provider(tmp_path) + _write_cli_overlay(tmp_path, self._MODEL, "xhigh") + + assert provider._apply_effort_overlay() is True + assert _read_cli_overlay(tmp_path) == {} + + def test_auto_session_clears_every_candidate_models_effort(self, tmp_path): + _write_cli_overlay(tmp_path, self._MODEL, "xhigh") + _write_cli_overlay(tmp_path, "gpt-5.6-sol", "max") + + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + assert provider._apply_effort_overlay() is True + + assert _read_cli_overlay(tmp_path) == {} + + def test_auto_session_preserves_non_object_model_defaults_while_dropping_stale_ownership( + self, tmp_path + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps( + { + "chat.modelDefaults": "operator-note", + _KIROCREW_EFFORT_OWNED_KEY: {self._MODEL: "max"}, + } + ), + encoding="utf-8", + ) + + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + assert provider._apply_effort_overlay() is True + + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data["chat.modelDefaults"] == "operator-note" + assert _KIROCREW_EFFORT_OWNED_KEY not in data + + def test_a_default_session_gives_a_directory_no_overlay(self, tmp_path): + # Nothing to clear means no lock, so no lock sidecar lands in a project. + self._provider(tmp_path) + + assert not (tmp_path / ".kiro").exists() + + def test_an_auto_session_gives_a_directory_no_overlay(self, tmp_path): + acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + + assert not (tmp_path / ".kiro").exists() + + def test_a_clear_that_loses_the_lock_is_reported(self, tmp_path, monkeypatch, caplog): + provider = self._provider(tmp_path) + _write_cli_overlay(tmp_path, self._MODEL, "max") + seen = {} + + @contextmanager + def _busy(_work_dir, *, timeout=None): + seen["timeout"] = timeout + raise OSError("lock busy") + yield # pragma: no cover - unreachable, keeps the generator shape + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "locked_workspace_cli_settings", _busy) + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert provider._apply_effort_overlay() is False + # A launch cannot wait, so the pre-spawn clear takes the startup ceiling. + assert seen["timeout"] == acp_provider.CLI_SETTINGS_LOCK_TIMEOUT_SECS + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + messages = [r.getMessage() for r in caplog.records] + assert any("could not be checked or cleared" in m for m in messages) + assert not any("was busy" in m for m in messages) + + def test_an_auto_clear_that_loses_the_lock_is_reported_by_consequence( + self, tmp_path, monkeypatch, caplog + ): + _write_cli_overlay(tmp_path, self._MODEL, "max") + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + + @contextmanager + def _busy(_work_dir, *, timeout=None): + raise OSError("lock busy") + yield # pragma: no cover - unreachable, keeps the generator shape + + with monkeypatch.context() as temporary_patches: + temporary_patches.setattr(acp_provider, "locked_workspace_cli_settings", _busy) + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert provider._apply_effort_overlay() is False + messages = [r.getMessage() for r in caplog.records] + assert any("could not be checked or cleared" in m for m in messages) + assert not any("was busy" in m for m in messages) + + def _pre_spawn_warnings(self, caplog, provider) -> list[str]: + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert provider._apply_effort_overlay() is True + return [ + r.getMessage() + for r in caplog.records + if r.levelno == logging.WARNING and r.name == acp_provider.__name__ + ] + + def test_default_spawn_warns_once_about_an_unowned_level_for_its_model(self, tmp_path, caplog): + self._write_operator_effort(tmp_path, self._MODEL, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + + warnings = self._pre_spawn_warnings(caplog, self._provider(tmp_path)) + + assert len(warnings) == 1 + assert self._MODEL in warnings[0] + assert "max" in warnings[0] + assert str(cli_json) in warnings[0] + assert "did not write" in warnings[0] + # The slot already has this model selected, so the picker remedy stands + # on its own. + assert warnings[0].endswith( + "Remove it by picking a level and then Default in the effort picker, " + "or by editing the file" + ) + assert cli_json.read_bytes() == before + + def test_unowned_warning_escapes_and_bounds_file_derived_entries(self, tmp_path, caplog): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + first_model = "first-model" + model_defaults = { + first_model: {"output_config": {"effort": "x" * 5000}}, + "bidi\u202eentry": {"output_config": {"effort": "max"}}, + "forged\nWARNING: not a separate record": {"output_config": {"effort": "high"}}, + } + for index in range(57): + model_defaults[f"model-{index}"] = {"output_config": {"effort": "max"}} + (settings / "cli.json").write_text( + json.dumps({"chat.modelDefaults": model_defaults}), encoding="utf-8" + ) + + warnings = self._pre_spawn_warnings( + caplog, + acp_provider.AcpProvider(acp_backend=ACP_BACKEND_KIRO, work_dir=tmp_path, model="auto"), + ) + + assert len(warnings) == 1 + assert first_model in warnings[0] + assert "\n" not in warnings[0] + assert "\\nWARNING" in warnings[0] + assert "\u202e" not in warnings[0] + assert "\\u202e" in warnings[0] + assert "... 50 more):" in warnings[0] + assert len(warnings[0]) < 1800 + + _CREDENTIAL = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + ) + + def test_unowned_warning_carries_no_fragment_of_a_credential_shaped_value_or_key( + self, tmp_path, caplog + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / "cli.json").write_text( + json.dumps( + { + "chat.modelDefaults": { + "credential-valued": {"output_config": {"effort": self._CREDENTIAL}}, + self._CREDENTIAL: {"output_config": {"effort": "max"}}, + } + } + ), + encoding="utf-8", + ) + + warnings = self._pre_spawn_warnings( + caplog, + acp_provider.AcpProvider(acp_backend=ACP_BACKEND_KIRO, work_dir=tmp_path, model="auto"), + ) + + assert len(warnings) == 1 + for cut in (len(self._CREDENTIAL), 120, 80, 40, 20): + assert self._CREDENTIAL[:cut] not in warnings[0] + assert "eyJ" not in warnings[0] + assert "" in warnings[0] + assert "credential-valued" in warnings[0] + assert '"max"' in warnings[0] + + def test_unowned_warning_bounds_long_and_deeply_nested_effort_values(self, tmp_path, caplog): + nested_effort: list[object] = [] + for _ in range(200_000): + nested_effort = [nested_effort] + + long_model_key = "nested-" + "m" * 5_000 + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + acp_provider._warn_unowned_cli_effort( + tmp_path / ".kiro" / "settings" / "cli.json", + { + "long": {"output_config": {"effort": "x" * 5_000}}, + long_model_key: {"output_config": {"effort": nested_effort}}, + }, + None, + ) + + warnings = [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING and record.name == acp_provider.__name__ + ] + assert len(warnings) == 1 + assert '"long"=' in warnings[0] + assert "" in warnings[0] + assert "" in warnings[0] + entries = warnings[0].split("Kiro Crew did not write (", 1)[1].split("):", 1)[0] + assert len(entries) <= 2 * acp_provider._WARN_UNOWNED_MAX_CHARS + 2 + + def test_kept_operator_value_is_escaped_and_bounded_in_debug_log(self, tmp_path, caplog): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps( + {"chat.modelDefaults": {self._MODEL: {"output_config": {"effort": "x" * 5000}}}} + ), + encoding="utf-8", + ) + + with caplog.at_level(logging.DEBUG, logger=acp_provider.__name__): + _write_cli_overlay(tmp_path, self._MODEL, "max") + + messages = [ + record.getMessage() + for record in caplog.records + if "kept operator-authored level" in record.getMessage() + ] + assert len(messages) == 1 + assert "effort=" in messages[0] + assert len(messages[0]) < 300 + + def test_auto_spawn_warns_about_an_unowned_level_for_any_model(self, tmp_path, caplog): + self._write_operator_effort(tmp_path, "gpt-5.6-sol", "high") + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + + warnings = self._pre_spawn_warnings(caplog, provider) + + assert len(warnings) == 1 + assert "gpt-5.6-sol" in warnings[0] + assert "high" in warnings[0] + # Its picker remedy is reachable only once the listed model is selected. + assert "selecting the listed model" in warnings[0] + assert _read_cli_overlay(tmp_path) == {"gpt-5.6-sol": "high"} + + def test_default_spawn_stays_silent_when_it_removes_its_own_level(self, tmp_path, caplog): + _write_cli_overlay(tmp_path, self._MODEL, "max") + + warnings = self._pre_spawn_warnings(caplog, self._provider(tmp_path)) + + assert warnings == [] + assert _read_cli_overlay(tmp_path) == {} + + def test_owned_clear_logs_removed_levels_once_with_escaped_bounded_values( + self, tmp_path, caplog + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + models = ["removed\nmodel"] + [f"model-{index}" for index in range(11)] + stamp = 1_700_000_000 + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps( + { + "chat.modelDefaults": { + model: {"output_config": {"effort": "lev\nel"}} for model in models + }, + _KIROCREW_EFFORT_OWNED_KEY: {model: "lev\nel" for model in models}, + "kirocrew.effortOwnedStamp": stamp, + } + ), + encoding="utf-8", + ) + os.utime(cli_json, (stamp, stamp)) + + with caplog.at_level(logging.INFO, logger=acp_provider.__name__): + assert _clear_cli_overlay_effort(tmp_path, None) is True + + removals = [ + record.getMessage() + for record in caplog.records + if "removed Kiro Crew-owned level" in record.getMessage() + ] + assert len(removals) == 1 + assert str(settings / "cli.json") in removals[0] + assert '"removed\\nmodel"=' in removals[0] + assert "\n" not in removals[0] + assert "... 2 more" in removals[0] + assert "set it again" in removals[0] + assert len(removals[0]) < 1800 + + def test_auto_spawn_stays_silent_when_it_removes_every_owned_level(self, tmp_path, caplog): + _write_cli_overlay(tmp_path, self._MODEL, "max") + _write_cli_overlay(tmp_path, "gpt-5.6-sol", "high") + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model="auto", + ) + + warnings = self._pre_spawn_warnings(caplog, provider) + + assert warnings == [] + assert _read_cli_overlay(tmp_path) == {} + + def test_default_spawn_stays_silent_about_another_models_unowned_level(self, tmp_path, caplog): + self._write_operator_effort(tmp_path, "claude-opus-4.6", "max") + + warnings = self._pre_spawn_warnings(caplog, self._provider(tmp_path)) + + assert warnings == [] + assert _read_cli_overlay(tmp_path) == {"claude-opus-4.6": "max"} + + @pytest.mark.parametrize("model", ["claude-opus-4.7", "auto"]) + def test_spawn_stays_silent_about_a_level_under_the_other_family_key( + self, tmp_path, caplog, model + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + (settings / "cli.json").write_text( + json.dumps({"chat.modelDefaults": {self._MODEL: {"reasoning": {"effort": "max"}}}}), + encoding="utf-8", + ) + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model=model, + ) + + warnings = self._pre_spawn_warnings(caplog, provider) + + assert warnings == [] + + @pytest.mark.asyncio + async def test_a_spawn_waits_out_a_brief_holder_of_the_lock(self, tmp_path, monkeypatch): + # Another chat's change_effort holds the lock off-loop across a read plus + # an atomic write. On the loop thread the lock is single-shot, so a + # spawn meeting that window on the loop would give up at once and read + # the level that chat left. The pre-spawn projection waits it out off the + # loop (the holder frees the lock well inside the startup ceiling). + provider = self._provider(tmp_path) + _write_cli_overlay(tmp_path, self._MODEL, "max") + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + held = threading.Event() + release = threading.Event() + + def _hold_the_lock_briefly(): + with workspace_cli_settings_lock(tmp_path): + held.set() + release.wait(timeout=5) + + holder = threading.Thread(target=_hold_the_lock_briefly) + try: + holder.start() + assert held.wait(timeout=5) + asyncio.get_running_loop().call_later(0.1, release.set) + await provider.start() + finally: + release.set() + holder.join(timeout=5) + assert not holder.is_alive() + + assert _read_cli_overlay(tmp_path) == {} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "overlay_writer", ["_apply_effort_overlay", "_apply_tool_search_overlay"] + ) + async def test_a_cancelled_spawn_waits_for_the_overlay_worker( + self, tmp_path, monkeypatch, overlay_writer + ): + provider = self._provider(tmp_path) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + worker_started = threading.Event() + release_worker = threading.Event() + worker_finished = threading.Event() + + def _blocked_overlay(): + worker_started.set() + try: + assert release_worker.wait(timeout=5) + finally: + worker_finished.set() + raise RuntimeError("abandoned overlay failed") + + monkeypatch.setattr(provider, overlay_writer, _blocked_overlay) + start_task = None + try: + start_task = asyncio.create_task(provider.start()) + await async_wait_until( + worker_started.is_set, describe=lambda: "worker_started was never set" + ) + start_task.cancel() + await asyncio.sleep(0) + assert not start_task.done() + assert not worker_finished.is_set() + finally: + release_worker.set() + if start_task is not None and not start_task.done(): + if start_task.cancelling() == 0: + start_task.cancel() + with suppress(asyncio.CancelledError): + await _await_test(start_task, "start_task") + + assert start_task is not None + await async_wait_until( + worker_finished.is_set, describe=lambda: "worker_finished was never set" + ) + with pytest.raises(asyncio.CancelledError): + await _await_test(start_task, "start_task") + assert worker_finished.is_set() + provider._start_kiro_runtime.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "overlay_writer", ["_apply_effort_overlay", "_apply_tool_search_overlay"] + ) + async def test_a_spawn_cancelled_again_while_it_waits_still_waits_for_the_worker( + self, tmp_path, monkeypatch, overlay_writer + ): + # A slot close and then a gateway shutdown each cancel the start; the + # second must not release a worker that could write after a successor. + provider = self._provider(tmp_path) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + worker_started = threading.Event() + release_worker = threading.Event() + worker_finished = threading.Event() + + def _blocked_overlay(): + worker_started.set() + try: + assert release_worker.wait(timeout=5) + finally: + worker_finished.set() + + monkeypatch.setattr(provider, overlay_writer, _blocked_overlay) + start_task = asyncio.create_task(provider.start()) + try: + await async_wait_until( + worker_started.is_set, describe=lambda: "worker_started was never set" + ) + for _ in range(3): + start_task.cancel() + await asyncio.sleep(0) + assert not start_task.done() + assert not worker_finished.is_set() + finally: + release_worker.set() + + with pytest.raises(asyncio.CancelledError): + await _await_test(start_task, "start_task") + assert worker_finished.is_set() + provider._start_kiro_runtime.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize("switch", ["set_model", "reapply_live_effort"]) + async def test_operator_authored_effort_survives_a_live_switch_on_default( + self, tmp_path, monkeypatch, switch + ): + # A live model switch on a slot whose effort is Default re-applies the + # resolved default to the model switched to, which runs clear_effort with + # no override and no workspace default. Nobody asked for that model's + # entry to be removed, so an entry the operator wrote for it by hand is + # not Kiro Crew's to delete; only the explicit clear endpoint may. + target = "claude-sonnet-4.6" + self._write_operator_effort(tmp_path, target, "max") + provider = self._provider(tmp_path) + provider._client.send_command = AsyncMock(return_value="") + + async def _switch_live(model): + provider._client._model = model + + monkeypatch.setattr(provider._client, "set_model", _switch_live) + + if switch == "set_model": + await provider.set_model(target) + else: + await _switch_live(target) + assert await provider.reapply_live_effort("") is True + + assert provider._client._model == target + assert _read_cli_overlay(tmp_path) == {target: "max"} + assert self._owned_effort(tmp_path) == {} + provider._client.send_command.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize("owned_only", [True, False]) + async def test_only_an_explicit_operator_clear_removes_an_unowned_entry( + self, tmp_path, owned_only + ): + # The dashboard's explicit clear is the one caller that passes + # owned_only=False: the operator has directly asked to remove this + # model's entry, whoever wrote it. Every automatic clear keeps the guard. + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider(tmp_path) + provider._client.send_command = AsyncMock(return_value="") + + assert await provider.clear_effort(owned_only=owned_only) is False + + expected = {self._MODEL: "max"} if owned_only else {} + assert _read_cli_overlay(tmp_path) == expected + provider._client.send_command.assert_not_awaited() + + @pytest.mark.asyncio + async def test_explicit_default_removes_operator_level_before_projecting_workspace_default( + self, tmp_path, monkeypatch + ): + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + provider._client.send_command = AsyncMock(return_value="") + real_atomic_write = acp_provider.atomic_write + writes = [] + + def _count_write(path, contents, **kwargs): + writes.append((path, contents)) + real_atomic_write(path, contents, **kwargs) + + monkeypatch.setattr(acp_provider, "atomic_write", _count_write) + assert await provider.clear_effort(owned_only=False) is True + + assert len(writes) == 1 + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + assert self._owned_effort(tmp_path) == {self._MODEL: "low"} + provider._client.send_command.assert_awaited_once_with("/effort", args={"level": "low"}) + + @pytest.mark.asyncio + async def test_explicit_default_reports_a_non_object_parent_and_leaves_the_file_unchanged( + self, tmp_path, caplog + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps({"chat.modelDefaults": "operator-note", "other.key": 1}), + encoding="utf-8", + ) + before = cli_json.read_bytes() + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + provider._client.send_command = AsyncMock(return_value="") + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert await provider.clear_effort(owned_only=False) is None + + assert cli_json.read_bytes() == before + assert provider._effort_per_model[self._MODEL] == "high" + provider._client.send_command.assert_not_awaited() + assert any( + "workspace overlay cannot be rewritten" in record.getMessage() + for record in caplog.records + ) + + @pytest.mark.asyncio + async def test_explicit_default_that_cannot_publish_the_default_leaves_the_file_unchanged( + self, tmp_path, monkeypatch, caplog + ): + self._write_operator_effort(tmp_path, self._MODEL, "max") + cli_json = tmp_path / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + provider._client.send_command = AsyncMock(return_value="") + real_atomic_write = acp_provider.atomic_write + + def _fail_default_publication(path, contents, **kwargs): + document = json.loads(contents) + model_defaults = document.get("chat.modelDefaults", {}) + model_config = model_defaults.get(self._MODEL, {}) + output_config = model_config.get("output_config", {}) + if output_config.get("effort") == "low": + raise OSError("lock busy") + real_atomic_write(path, contents, **kwargs) + + monkeypatch.setattr(acp_provider, "atomic_write", _fail_default_publication) + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert await provider.clear_effort(owned_only=False) is None + + assert cli_json.read_bytes() == before + assert provider._effort_per_model[self._MODEL] == "high" + provider._client.send_command.assert_not_awaited() + assert any( + "workspace overlay cannot be rewritten" in r.getMessage() for r in caplog.records + ) + + @pytest.mark.asyncio + async def test_explicit_default_clears_the_other_family_shape_and_keeps_sibling_keys( + self, tmp_path + ): + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + json.dumps( + { + "chat.modelDefaults": { + self._MODEL: { + "output_config": {"effort": "max", "active_sibling": True}, + "reasoning": {"effort": "high", "other_sibling": "kept"}, + } + } + } + ), + encoding="utf-8", + ) + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + provider._client.send_command = AsyncMock(return_value="") + + assert await provider.clear_effort(owned_only=False) is True + + model_cfg = json.loads(cli_json.read_text(encoding="utf-8"))["chat.modelDefaults"][ + self._MODEL + ] + assert model_cfg["output_config"] == {"active_sibling": True, "effort": "low"} + assert model_cfg["reasoning"] == {"other_sibling": "kept"} + + @pytest.mark.asyncio + @pytest.mark.parametrize("workspace_default", [None, "low"]) + async def test_the_explicit_clear_rewrites_inside_its_fence_and_pushes_after_it( + self, tmp_path, monkeypatch, workspace_default + ): + # The fence refuses warm-runtime claims while it is up and dates the + # rewrite as it comes down; the live push can wait on a busy session. + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: workspace_default} if workspace_default else None, + ) + events: list[str] = [] + provider._client.send_command = AsyncMock( + side_effect=lambda *_a, **_k: events.append("push") + ) + + @contextmanager + def fence(): + events.append("fence up") + try: + yield + finally: + events.append("fence down") + + real_atomic_write = acp_provider.atomic_write + + def record_write(path, contents, **kwargs): + events.append("write") + real_atomic_write(path, contents, **kwargs) + + monkeypatch.setattr(acp_provider, "atomic_write", record_write) + + applied_live = await provider.clear_effort(owned_only=False, fence_rewrite=fence) + + assert applied_live is (workspace_default is not None) + pushes = ["push"] if workspace_default else [] + assert events == ["fence up", "write", "fence down", *pushes] + expected = {self._MODEL: workspace_default} if workspace_default else {} + assert _read_cli_overlay(tmp_path) == expected + + @pytest.mark.asyncio + @pytest.mark.parametrize("workspace_default", [None, "low"]) + async def test_a_cancelled_explicit_clear_lowers_its_fence_only_after_the_write( + self, tmp_path, monkeypatch, workspace_default + ): + # A fence lowered while the worker can still write dates the rewrite too + # early: a runtime spawned in between could read the entry it removes. + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: workspace_default} if workspace_default else None, + ) + provider._client.send_command = AsyncMock(return_value="") + worker_started = threading.Event() + release_worker = threading.Event() + worker_finished = threading.Event() + fence_down_after_the_worker: list[bool] = [] + real_atomic_write = acp_provider.atomic_write + + def blocked_write(path, contents, **kwargs): + worker_started.set() + try: + assert release_worker.wait(timeout=5) + real_atomic_write(path, contents, **kwargs) + finally: + worker_finished.set() + + @contextmanager + def fence(): + try: + yield + finally: + fence_down_after_the_worker.append(worker_finished.is_set()) + + monkeypatch.setattr(acp_provider, "atomic_write", blocked_write) + clear_task = asyncio.create_task( + provider.clear_effort(owned_only=False, fence_rewrite=fence) + ) + try: + await async_wait_until( + worker_started.is_set, describe=lambda: "worker_started was never set" + ) + clear_task.cancel() + await asyncio.sleep(0) + assert not clear_task.done() + assert fence_down_after_the_worker == [] + finally: + release_worker.set() + + with pytest.raises(asyncio.CancelledError): + await _await_test(clear_task, "clear_task") + assert fence_down_after_the_worker == [True] + provider._client.send_command.assert_not_awaited() + + @pytest.mark.asyncio + async def test_an_explicit_clear_whose_push_fails_after_the_rewrite_asks_for_a_reset( + self, tmp_path, caplog + ): + # The file already holds the default, so a reset carries it to the + # session. A raise would make the handler carry a removal the file no + # longer needs, and refuse the pick when that Default cannot be saved. + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + provider._client.send_command = AsyncMock(side_effect=RuntimeError("runtime gone")) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + assert await provider.clear_effort(owned_only=False) is False + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + assert self._MODEL not in provider._effort_per_model + assert any("live push failed" in r.getMessage() for r in caplog.records) + + @pytest.mark.asyncio + async def test_an_automatic_clear_whose_push_fails_still_raises(self, tmp_path): + # reapply_live_effort and the handler's undo read a raise as the live + # re-apply failing, and False as nothing left to push. + provider = self._provider(tmp_path, effort_defaults={self._MODEL: "low"}) + provider._client.send_command = AsyncMock(side_effect=RuntimeError("runtime gone")) + + with pytest.raises(RuntimeError, match="runtime gone"): + await provider.clear_effort() + + @pytest.mark.asyncio + async def test_an_automatic_clear_never_replaces_an_unowned_value(self, tmp_path, monkeypatch): + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider(tmp_path, effort_defaults={self._MODEL: "low"}) + provider._client.send_command = AsyncMock(return_value="") + real_write = acp_provider._write_cli_overlay + replace_unowned_values = [] + + def _track_write(*args, **kwargs): + replace_unowned_values.append(kwargs.get("replace_unowned", False)) + return real_write(*args, **kwargs) + + monkeypatch.setattr(acp_provider, "_write_cli_overlay", _track_write) + assert provider._apply_effort_overlay() is True + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + + assert await provider.change_effort("high") is True + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + + assert await provider.clear_effort() is True + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + assert replace_unowned_values == [False, False, False] + + @pytest.mark.asyncio + async def test_automatic_default_keeps_operator_level_and_pushes_workspace_default( + self, tmp_path + ): + self._write_operator_effort(tmp_path, self._MODEL, "max") + provider = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + provider._client.send_command = AsyncMock(return_value="") + + assert await provider.clear_effort() is True + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + assert self._owned_effort(tmp_path) == {} + provider._client.send_command.assert_awaited_once_with("/effort", args={"level": "low"}) + + +class TestAPickedLevelSurvivesAPeersClear: + """What a kiro session runs is its own pick, whatever the shared file says. + + The overlay is projected before the spawn and read by kiro-cli at the spawn. + Between the two, another session in the same work dir can project its own + Default and remove this session's entry: an unpinned session removes every + Kiro Crew entry, a session on the same model removes this model's. The warm + pool constructs such a session on every fill and every replenish, in the + dashboard's own work dir, so the gap is met routinely. The session cannot + stop a peer from projecting, so it pushes its level live once its session is + up, the same ``/effort`` channel a live change uses. + """ + + _MODEL = "claude-opus-4.7" + + def _provider(self, work_dir, *, model=None, **kwargs): + return acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=work_dir, + model=model or self._MODEL, + **kwargs, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("peer_model", ["auto", _MODEL]) + async def test_a_peers_clear_during_the_spawn_does_not_change_what_this_session_runs( + self, tmp_path, monkeypatch, peer_model + ): + picked = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + picked._client.send_command = AsyncMock(return_value="") + overlay_at_spawn = {} + + async def _spawn_while_a_default_peer_starts(): + # A warm-pool fill or a Default chat projects its own Default here, + # before kiro-cli has read the picked session's overlay. + peer = self._provider(tmp_path, model=peer_model) + assert peer._apply_effort_overlay() is True + overlay_at_spawn.update(_read_cli_overlay(tmp_path)) + + monkeypatch.setattr(picked, "_start_kiro_runtime", _spawn_while_a_default_peer_starts) + + await picked.start() + + assert overlay_at_spawn == {} + picked._client.send_command.assert_awaited_once_with("/effort", args={"level": "max"}) + + @pytest.mark.asyncio + async def test_a_session_on_default_pushes_nothing_after_the_spawn(self, tmp_path, monkeypatch): + provider = self._provider(tmp_path) + provider._client.send_command = AsyncMock(return_value="") + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + + await provider.start() + + provider._client.send_command.assert_not_awaited() + + @pytest.mark.asyncio + async def test_a_rejected_live_push_does_not_break_the_start( + self, tmp_path, monkeypatch, caplog + ): + provider = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + provider._client.send_command = AsyncMock(side_effect=RuntimeError("refused")) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + await provider.start() + + assert any("effort" in r.getMessage() and "max" in r.getMessage() for r in caplog.records) + + +class TestEffortPathsDoNoFilesystemIoOnTheLoop: + """Async effort paths keep work-directory filesystem calls off the loop.""" + + _MODEL = "claude-opus-4.7" + + def _provider(self, work_dir: Path, *, model: str | None = None, **kwargs): + return acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=work_dir, + model=model or self._MODEL, + **kwargs, + ) + + @staticmethod + def _on_loop(calls: list[tuple[str, int]], loop_thread: int) -> list[str]: + return [name for name, ident in calls if ident == loop_thread] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "provider_kwargs", + [{}, {"effort_per_model": {_MODEL: "max"}}], + ids=["default-spawn", "level-spawn"], + ) + async def test_start_does_no_work_dir_io_on_the_loop_thread( + self, tmp_path, monkeypatch, provider_kwargs + ): + work_dir = tmp_path / "ws" + work_dir.mkdir() + provider = self._provider(work_dir, **provider_kwargs) + provider._client.send_command = AsyncMock(return_value="") + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock(return_value=None)) + loop_thread = threading.get_ident() + + with _record_work_dir_io(monkeypatch, work_dir) as calls: + await provider.start() + + assert self._on_loop(calls, loop_thread) == [] + assert [name for name, ident in calls if ident != loop_thread] + + @pytest.mark.asyncio + async def test_change_effort_does_no_work_dir_io_on_the_loop_thread( + self, tmp_path, monkeypatch + ): + work_dir = tmp_path / "ws" + work_dir.mkdir() + provider = self._provider(work_dir) + provider._client.send_command = AsyncMock(return_value="") + loop_thread = threading.get_ident() + + with _record_work_dir_io(monkeypatch, work_dir) as calls: + assert await provider.change_effort("max") is True + + assert self._on_loop(calls, loop_thread) == [] + + @pytest.mark.asyncio + @pytest.mark.parametrize("workspace_default", [False, True], ids=["builtin", "workspace"]) + async def test_clear_effort_does_no_work_dir_io_on_the_loop_thread( + self, tmp_path, monkeypatch, workspace_default + ): + work_dir = tmp_path / "ws" + work_dir.mkdir() + provider_kwargs = {"effort_per_model": {self._MODEL: "max"}} + if workspace_default: + provider_kwargs["effort_defaults"] = {self._MODEL: "low"} + provider = self._provider(work_dir, **provider_kwargs) + provider._client.send_command = AsyncMock(return_value="") + assert await provider.change_effort("max") is True + loop_thread = threading.get_ident() + + with _record_work_dir_io(monkeypatch, work_dir) as calls: + result = await provider.clear_effort(owned_only=False) + + assert result is (True if workspace_default else False) + assert self._on_loop(calls, loop_thread) == [] diff --git a/test/test_effort_spawn_fence.py b/test/test_effort_spawn_fence.py new file mode 100644 index 00000000000..2f8f11d05c2 --- /dev/null +++ b/test/test_effort_spawn_fence.py @@ -0,0 +1,959 @@ +"""cli.json overlay tests: the pre-spawn projection and its fence. + +Default projections share their read window while level writes exclude it. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import threading +from contextlib import nullcontext +from unittest.mock import AsyncMock + +import pytest +from effort_helpers import _observe_directory_wait, _read_cli_overlay +from test_effort import _await_test + +from kiro_crew import platform_compat +from kiro_crew.acp.types import ACP_BACKEND_KIRO +from kiro_crew.providers import acp as acp_provider +from kiro_crew.providers.acp import _write_cli_overlay +from kiro_crew.testing.wait import async_wait_until + +pytestmark = pytest.mark.usefixtures("no_zero_tool_check") + + +class TestDefaultSpawnOverlayFence: + """Default projections share their read window while level writes exclude it.""" + + _MODEL = "claude-opus-4.7" + # A call the fence lets through still does real file I/O in a worker thread, which + # a loaded Windows runner has taken past 0.2 s. A call the fence wrongly holds waits + # out a 30 s or 32 s ceiling, so this deadline still tells the two apart. + _UNBLOCKED_DEADLINE_SECS = 10.0 + + def _provider(self, work_dir, *, model=None, **kwargs): + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=work_dir, + model=model or self._MODEL, + **kwargs, + ) + # Each test stubs the runtime start, so the provider keeps the AcpClient it + # was built with, and a level's post-spawn /effort push would start that + # client for real and leave its skill projection to the garbage collector. + provider._client.send_command = AsyncMock(return_value="") + return provider + + @staticmethod + async def _until(condition, timeout: float) -> None: + """Wait for fence state another task reaches only after its key resolves. + + A holder resolves its key in a worker thread before it reaches the fence, + so one loop turn does not show that another task is waiting yet. + """ + await async_wait_until(condition, timeout=timeout) + + @staticmethod + def _observe_write_fence_acquire(monkeypatch) -> tuple[asyncio.Event, asyncio.Event]: + write_waiting = asyncio.Event() + write_acquired = asyncio.Event() + original_acquire = acp_provider._WorkspaceEffortOverlayFence.acquire + + async def observed_acquire(fence, mode, timeout): + if mode == "write": + write_waiting.set() + acquired = await original_acquire(fence, mode, timeout) + if mode == "write" and acquired: + write_acquired.set() + return acquired + + monkeypatch.setattr(acp_provider._WorkspaceEffortOverlayFence, "acquire", observed_acquire) + return write_waiting, write_acquired + + @pytest.mark.asyncio + async def test_default_spawn_keeps_a_live_pick_out_of_its_runtime_start( + self, tmp_path, monkeypatch + ): + _write_cli_overlay(tmp_path, self._MODEL, "max") + default = self._provider(tmp_path) + sibling = self._provider(tmp_path) + sibling._client.send_command = AsyncMock(return_value="") + write_waiting, write_acquired = self._observe_write_fence_acquire(monkeypatch) + entered = asyncio.Event() + release = asyncio.Event() + + async def hold_runtime_start(): + entered.set() + await _await_test(release.wait(), "release") + + monkeypatch.setattr(default, "_start_kiro_runtime", hold_runtime_start) + start_task = asyncio.create_task(default.start()) + await _await_test(entered.wait(), "entered") + pick_task = asyncio.create_task(sibling.change_effort("max")) + await _await_test(write_waiting.wait(), "pick reaching the write fence") + assert not write_acquired.is_set(), "the write acquired through an open window" + + assert _read_cli_overlay(tmp_path) == {} + assert not pick_task.done() + + release.set() + await _await_test(start_task, "start_task") + assert await _await_test(pick_task, "pick_task") is True + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + + @pytest.mark.asyncio + async def test_second_default_spawn_enters_while_first_default_window_is_open( + self, tmp_path, monkeypatch, caplog + ): + _write_cli_overlay(tmp_path, self._MODEL, "max") + first = self._provider(tmp_path) + second = self._provider(tmp_path) + first_entered = asyncio.Event() + second_entered = asyncio.Event() + release_first = asyncio.Event() + + async def first_runtime_start(): + first_entered.set() + await _await_test(release_first.wait(), "release_first") + + async def second_runtime_start(): + second_entered.set() + + monkeypatch.setattr(first, "_start_kiro_runtime", first_runtime_start) + monkeypatch.setattr(second, "_start_kiro_runtime", second_runtime_start) + first_task = asyncio.create_task(first.start()) + await _await_test(first_entered.wait(), "first_entered") + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + second_task = asyncio.create_task(second.start()) + await asyncio.wait_for(second_entered.wait(), self._UNBLOCKED_DEADLINE_SECS) + + assert not any("could not be checked or cleared" in r.getMessage() for r in caplog.records) + release_first.set() + await _await_test(first_task, "first_task") + await _await_test(second_task, "second_task") + + @pytest.mark.asyncio + async def test_a_default_window_arriving_after_a_queued_write_waits_for_it( + self, tmp_path, monkeypatch + ): + # Fairness: a Default start that arrives while a write is queued waits for + # that write. Back-to-back Default starts otherwise overtake the pick until + # its ceiling expires. + _write_cli_overlay(tmp_path, self._MODEL, "max") + first = self._provider(tmp_path) + second = self._provider(tmp_path) + writer = self._provider(tmp_path) + writer._client.send_command = AsyncMock(return_value="") + first_entered = asyncio.Event() + second_entered = asyncio.Event() + release_first = asyncio.Event() + write_landed_when_second_entered: list[bool] = [] + + async def first_runtime_start(): + first_entered.set() + await _await_test(release_first.wait(), "release_first") + + async def second_runtime_start(): + write_landed_when_second_entered.append(write_acquired.is_set()) + second_entered.set() + + monkeypatch.setattr(first, "_start_kiro_runtime", first_runtime_start) + monkeypatch.setattr(second, "_start_kiro_runtime", second_runtime_start) + first_task = asyncio.create_task(first.start()) + await _await_test(first_entered.wait(), "first_entered") + write_waiting, write_acquired = self._observe_write_fence_acquire(monkeypatch) + writer_task = asyncio.create_task(writer.change_effort("max")) + await asyncio.wait_for(write_waiting.wait(), self._UNBLOCKED_DEADLINE_SECS) + directory_wait = _observe_directory_wait(monkeypatch) + second_task = asyncio.create_task(second.start()) + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + await _await_test(directory_wait.wait(), "the second start waiting for the directory") + assert fence._windows == 1 + assert not second_entered.is_set(), "the second window overtook the queued write" + assert not writer_task.done() + + release_first.set() + await _await_test(first_task, "first_task") + assert await _await_test(writer_task, "writer_task") is True + await asyncio.wait_for(second_entered.wait(), self._UNBLOCKED_DEADLINE_SECS) + await _await_test(second_task, "second_task") + assert write_landed_when_second_entered == [True], "the second start overtook the write" + + @pytest.mark.asyncio + async def test_default_window_is_keyed_by_work_directory(self, tmp_path, monkeypatch): + first_dir = tmp_path / "first" + second_dir = tmp_path / "second" + _write_cli_overlay(first_dir, self._MODEL, "max") + first = self._provider(first_dir) + other = self._provider(second_dir) + other._client.send_command = AsyncMock(return_value="") + entered = asyncio.Event() + release = asyncio.Event() + + async def hold_runtime_start(): + entered.set() + await _await_test(release.wait(), "release") + + monkeypatch.setattr(first, "_start_kiro_runtime", hold_runtime_start) + start_task = asyncio.create_task(first.start()) + await _await_test(entered.wait(), "entered") + assert ( + await asyncio.wait_for(other.change_effort("max"), self._UNBLOCKED_DEADLINE_SECS) + is True + ) + release.set() + await _await_test(start_task, "start_task") + + @pytest.mark.asyncio + async def test_auto_spawn_keeps_a_different_models_pick_out_of_runtime_start( + self, tmp_path, monkeypatch + ): + other_model = "claude-sonnet-4.6" + _write_cli_overlay(tmp_path, other_model, "max") + default = self._provider(tmp_path, model="auto") + sibling = self._provider(tmp_path, model=other_model) + sibling._client.send_command = AsyncMock(return_value="") + write_waiting, write_acquired = self._observe_write_fence_acquire(monkeypatch) + entered = asyncio.Event() + release = asyncio.Event() + + async def hold_runtime_start(): + entered.set() + await _await_test(release.wait(), "release") + + monkeypatch.setattr(default, "_start_kiro_runtime", hold_runtime_start) + start_task = asyncio.create_task(default.start()) + await _await_test(entered.wait(), "entered") + pick_task = asyncio.create_task(sibling.change_effort("max")) + await _await_test(write_waiting.wait(), "pick reaching the write fence") + assert not write_acquired.is_set(), "the write acquired through an open window" + assert _read_cli_overlay(tmp_path) == {} + assert not pick_task.done() + release.set() + await _await_test(start_task, "start_task") + assert await _await_test(pick_task, "pick_task") is True + + @pytest.mark.asyncio + async def test_explicit_default_with_workspace_default_waits_for_default_window( + self, tmp_path, monkeypatch + ): + _write_cli_overlay(tmp_path, self._MODEL, "max") + default = self._provider(tmp_path) + explicit = self._provider(tmp_path, effort_defaults={self._MODEL: "low"}) + explicit.arm_explicit_effort_default(nullcontext) + write_waiting, write_acquired = self._observe_write_fence_acquire(monkeypatch) + entered = asyncio.Event() + release = asyncio.Event() + explicit_started = asyncio.Event() + + async def hold_runtime_start(): + entered.set() + await _await_test(release.wait(), "release") + + async def explicit_runtime_start(): + explicit_started.set() + + monkeypatch.setattr(default, "_start_kiro_runtime", hold_runtime_start) + monkeypatch.setattr(explicit, "_start_kiro_runtime", explicit_runtime_start) + default_task = asyncio.create_task(default.start()) + await _await_test(entered.wait(), "entered") + explicit_task = asyncio.create_task(explicit.start()) + await _await_test(write_waiting.wait(), "explicit default reaching the write fence") + assert not write_acquired.is_set(), "the write acquired through an open window" + assert not explicit_started.is_set() + assert _read_cli_overlay(tmp_path) == {} + release.set() + await _await_test(default_task, "default_task") + await _await_test(explicit_task, "explicit_task") + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + + @pytest.mark.asyncio + async def test_level_projection_waits_for_default_window_then_reasserts_live_level( + self, tmp_path, monkeypatch + ): + default = self._provider(tmp_path) + level = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + level._client.send_command = AsyncMock(return_value="") + write_waiting, write_acquired = self._observe_write_fence_acquire(monkeypatch) + entered = asyncio.Event() + release = asyncio.Event() + level_started = asyncio.Event() + + async def hold_runtime_start(): + entered.set() + await _await_test(release.wait(), "release") + + async def level_runtime_start(): + level_started.set() + + monkeypatch.setattr(default, "_start_kiro_runtime", hold_runtime_start) + monkeypatch.setattr(level, "_start_kiro_runtime", level_runtime_start) + default_task = asyncio.create_task(default.start()) + await _await_test(entered.wait(), "entered") + level_task = asyncio.create_task(level.start()) + await _await_test(write_waiting.wait(), "level projection reaching the write fence") + assert not write_acquired.is_set(), "the write acquired through an open window" + assert not level_started.is_set() + assert _read_cli_overlay(tmp_path) == {} + release.set() + await _await_test(default_task, "default_task") + await _await_test(level_task, "level_task") + level._client.send_command.assert_awaited_once_with("/effort", args={"level": "max"}) + + @pytest.mark.asyncio + async def test_level_spawn_releases_write_fence_before_runtime_start( + self, tmp_path, monkeypatch + ): + level = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + sibling = self._provider(tmp_path) + level._client.send_command = AsyncMock(return_value="") + sibling._client.send_command = AsyncMock(return_value="") + entered = asyncio.Event() + release = asyncio.Event() + + async def hold_runtime_start(): + entered.set() + await _await_test(release.wait(), "release") + + monkeypatch.setattr(level, "_start_kiro_runtime", hold_runtime_start) + level_task = asyncio.create_task(level.start()) + await _await_test(entered.wait(), "entered") + assert ( + await asyncio.wait_for(sibling.change_effort("high"), self._UNBLOCKED_DEADLINE_SECS) + is True + ) + release.set() + await _await_test(level_task, "level_task") + + @pytest.mark.asyncio + async def test_expired_window_fails_before_tool_search_or_runtime(self, tmp_path, monkeypatch): + holder = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + waiter = self._provider(tmp_path) + projection_started = threading.Event() + release_projection = threading.Event() + runtime_started = asyncio.Event() + tool_search_calls = [] + + def hold_level_projection(): + projection_started.set() + assert release_projection.wait(timeout=5) + return True + + def apply_tool_search_overlay(): + tool_search_calls.append(True) + return True + + async def waiting_runtime_start(): + runtime_started.set() + + monkeypatch.setattr(holder, "_apply_effort_overlay", hold_level_projection) + monkeypatch.setattr(holder, "_start_kiro_runtime", AsyncMock()) + monkeypatch.setattr(waiter, "_apply_tool_search_overlay", apply_tool_search_overlay) + monkeypatch.setattr(waiter, "_start_kiro_runtime", waiting_runtime_start) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_TIMEOUT_SECS", 0.01) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS", 0.02) + monkeypatch.setattr( + acp_provider, + "_WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS", + 0.03, + raising=False, + ) + holder_task = asyncio.create_task(holder.start()) + await async_wait_until( + projection_started.is_set, describe=lambda: "projection_started was never set" + ) + + with pytest.raises(RuntimeError, match="Default effort overlay fence wait timed out"): + await waiter.start() + + assert tool_search_calls == [] + assert not runtime_started.is_set() + release_projection.set() + await _await_test(holder_task, "holder_task") + + @pytest.mark.asyncio + async def test_cancelled_window_releases_after_its_overlay_worker_settles( + self, tmp_path, monkeypatch + ): + provider = self._provider(tmp_path) + sibling = self._provider(tmp_path) + sibling._client.send_command = AsyncMock(return_value="") + worker_started = threading.Event() + release_worker = threading.Event() + + def blocked_overlay(): + worker_started.set() + assert release_worker.wait(timeout=5) + return True + + monkeypatch.setattr(provider, "_apply_effort_overlay", blocked_overlay) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + start_task = asyncio.create_task(provider.start()) + await async_wait_until( + worker_started.is_set, describe=lambda: "worker_started was never set" + ) + start_task.cancel() + await asyncio.sleep(0) + pick_task = asyncio.create_task(sibling.change_effort("max")) + await asyncio.sleep(0) + assert not pick_task.done() + release_worker.set() + with pytest.raises(asyncio.CancelledError): + await _await_test(start_task, "start_task") + assert await _await_test(pick_task, "pick_task") is True + + @pytest.mark.asyncio + async def test_cancelled_waiting_start_does_not_block_next_write(self, tmp_path, monkeypatch): + holder = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + waiter = self._provider(tmp_path) + writer = self._provider(tmp_path) + writer._client.send_command = AsyncMock(return_value="") + projection_started = threading.Event() + release_projection = threading.Event() + + def hold_level_projection(): + projection_started.set() + assert release_projection.wait(timeout=5) + return True + + monkeypatch.setattr(holder, "_apply_effort_overlay", hold_level_projection) + monkeypatch.setattr(holder, "_start_kiro_runtime", AsyncMock()) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_TIMEOUT_SECS", 0.01) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS", 0.02) + monkeypatch.setattr( + acp_provider, + "_WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS", + self._UNBLOCKED_DEADLINE_SECS, + raising=False, + ) + holder_task = asyncio.create_task(holder.start()) + await async_wait_until( + projection_started.is_set, describe=lambda: "projection_started was never set" + ) + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + directory_wait = _observe_directory_wait(monkeypatch) + waiter_task = asyncio.create_task(waiter.start()) + await _await_test(directory_wait.wait(), "the waiter waiting for the directory") + assert fence._pending_windows == 0, "a waiting start registered on the fence" + waiter_task.cancel() + with pytest.raises(asyncio.CancelledError): + await _await_test(waiter_task, "waiter_task") + + release_projection.set() + await _await_test(holder_task, "holder_task") + assert ( + await asyncio.wait_for(writer.change_effort("high"), self._UNBLOCKED_DEADLINE_SECS) + is True + ) + + @pytest.mark.asyncio + async def test_pick_fence_expiry_raises_without_writing(self, tmp_path, monkeypatch): + _write_cli_overlay(tmp_path, self._MODEL, "max") + holder = self._provider(tmp_path) + picker = self._provider(tmp_path) + picker._client.send_command = AsyncMock(return_value="") + holder_entered = asyncio.Event() + release_holder = asyncio.Event() + + async def hold_runtime_start(): + holder_entered.set() + await _await_test(release_holder.wait(), "release_holder") + + monkeypatch.setattr(holder, "_start_kiro_runtime", hold_runtime_start) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS", 0.01) + holder_task = asyncio.create_task(holder.start()) + await _await_test(holder_entered.wait(), "holder_entered") + + with pytest.raises( + RuntimeError, match="could not persist effort.*workspace overlay is locked" + ): + await picker.change_effort("max") + assert _read_cli_overlay(tmp_path) == {} + assert picker._effort_per_model == {} + release_holder.set() + await _await_test(holder_task, "holder_task") + + @pytest.mark.asyncio + async def test_workspace_default_clear_fence_expiry_returns_none_without_writing( + self, tmp_path, monkeypatch + ): + _write_cli_overlay(tmp_path, self._MODEL, "high") + holder = self._provider(tmp_path) + clearer = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + clearer._client.send_command = AsyncMock(return_value="") + holder_entered = asyncio.Event() + release_holder = asyncio.Event() + + async def hold_runtime_start(): + holder_entered.set() + await _await_test(release_holder.wait(), "release_holder") + + monkeypatch.setattr(holder, "_start_kiro_runtime", hold_runtime_start) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS", 0.01) + holder_task = asyncio.create_task(holder.start()) + await _await_test(holder_entered.wait(), "holder_entered") + + assert await clearer.clear_effort(owned_only=False) is None + assert _read_cli_overlay(tmp_path) == {} + assert clearer._effort_per_model == {self._MODEL: "high"} + clearer._client.send_command.assert_not_awaited() + release_holder.set() + await _await_test(holder_task, "holder_task") + + @pytest.mark.asyncio + async def test_builtin_default_clear_fence_expiry_returns_none_without_writing( + self, tmp_path, monkeypatch + ): + _write_cli_overlay(tmp_path, self._MODEL, "high") + holder = self._provider(tmp_path) + clearer = self._provider(tmp_path, effort_per_model={self._MODEL: "high"}) + holder_entered = asyncio.Event() + release_holder = asyncio.Event() + + async def hold_runtime_start(): + holder_entered.set() + await _await_test(release_holder.wait(), "release_holder") + + monkeypatch.setattr(holder, "_start_kiro_runtime", hold_runtime_start) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS", 0.01) + holder_task = asyncio.create_task(holder.start()) + await _await_test(holder_entered.wait(), "holder_entered") + + assert await clearer.clear_effort(owned_only=False) is None + assert _read_cli_overlay(tmp_path) == {} + assert clearer._effort_per_model == {self._MODEL: "high"} + release_holder.set() + await _await_test(holder_task, "holder_task") + + @pytest.mark.asyncio + async def test_live_workspace_default_clear_waits_for_a_default_window( + self, tmp_path, monkeypatch + ): + _write_cli_overlay(tmp_path, self._MODEL, "high") + holder = self._provider(tmp_path) + clearer = self._provider( + tmp_path, + effort_per_model={self._MODEL: "high"}, + effort_defaults={self._MODEL: "low"}, + ) + clearer._client.send_command = AsyncMock(return_value="") + holder_entered = asyncio.Event() + release_holder = asyncio.Event() + clear_write_started = threading.Event() + original_apply = clearer._apply_effort_overlay + write_waiting, write_acquired = self._observe_write_fence_acquire(monkeypatch) + + async def hold_runtime_start(): + holder_entered.set() + await _await_test(release_holder.wait(), "release_holder") + + def observed_clear_write(**kwargs): + clear_write_started.set() + return original_apply(**kwargs) + + monkeypatch.setattr(holder, "_start_kiro_runtime", hold_runtime_start) + monkeypatch.setattr(clearer, "_apply_effort_overlay", observed_clear_write) + holder_task = asyncio.create_task(holder.start()) + await _await_test(holder_entered.wait(), "holder_entered") + clear_task = asyncio.create_task(clearer.clear_effort(owned_only=False)) + await _await_test(write_waiting.wait(), "clear reaching the write fence") + assert not write_acquired.is_set(), "the write acquired through an open window" + assert not clear_write_started.is_set() + assert _read_cli_overlay(tmp_path) == {} + + release_holder.set() + await _await_test(holder_task, "holder_task") + assert await _await_test(clear_task, "clear_task") is True + assert _read_cli_overlay(tmp_path) == {self._MODEL: "low"} + + @pytest.mark.asyncio + async def test_sequential_writes_release_the_fence_and_default_start_enters( + self, tmp_path, monkeypatch + ): + first = self._provider(tmp_path) + second = self._provider(tmp_path) + default = self._provider(tmp_path) + first._client.send_command = AsyncMock(return_value="") + second._client.send_command = AsyncMock(return_value="") + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS", 0.01) + monkeypatch.setattr(default, "_start_kiro_runtime", AsyncMock()) + + assert await first.change_effort("max") is True + assert await second.change_effort("high") is True + await asyncio.wait_for(default.start(), self._UNBLOCKED_DEADLINE_SECS) + assert _read_cli_overlay(tmp_path) == {} + + @pytest.mark.asyncio + async def test_symlinked_alias_shares_one_fence(self, tmp_path): + real_work_dir = tmp_path / "real" + alias_work_dir = tmp_path / "alias" + real_work_dir.mkdir() + try: + platform_compat.symlink_or_junction(real_work_dir, alias_work_dir) + except OSError as exc: + pytest.fail(f"platform cannot create a directory symlink: {exc}") + real_provider = self._provider(real_work_dir) + alias_provider = self._provider(alias_work_dir) + + async with acp_provider._hold_workspace_effort_overlay_fence( + real_provider._client._work_dir, "write", self._UNBLOCKED_DEADLINE_SECS + ) as acquired: + assert acquired is True + async with acp_provider._hold_workspace_effort_overlay_fence( + alias_provider._client._work_dir, "write", self._UNBLOCKED_DEADLINE_SECS + ) as alias_acquired: + assert alias_acquired is False + + @pytest.mark.asyncio + async def test_work_dirs_sharing_one_settings_folder_share_one_fence(self, tmp_path): + outer_work_dir = tmp_path / "outer" + inner_work_dir = outer_work_dir / "sub" + (inner_work_dir / ".kiro" / "settings").mkdir(parents=True) + try: + platform_compat.symlink_or_junction(inner_work_dir / ".kiro", outer_work_dir / ".kiro") + except OSError as exc: + pytest.fail(f"platform cannot create a directory symlink: {exc}") + outer_provider = self._provider(outer_work_dir) + inner_provider = self._provider(inner_work_dir) + + async with acp_provider._hold_workspace_effort_overlay_fence( + outer_provider._client._work_dir, "write", self._UNBLOCKED_DEADLINE_SECS + ) as acquired: + assert acquired is True + async with acp_provider._hold_workspace_effort_overlay_fence( + inner_provider._client._work_dir, "write", self._UNBLOCKED_DEADLINE_SECS + ) as inner_acquired: + assert inner_acquired is False + + @pytest.mark.asyncio + async def test_default_window_through_symlink_blocks_real_work_directory_write(self, tmp_path): + real_work_dir = tmp_path / "real" + alias_work_dir = tmp_path / "alias" + real_work_dir.mkdir() + try: + platform_compat.symlink_or_junction(real_work_dir, alias_work_dir) + except OSError as exc: + pytest.fail(f"platform cannot create a directory symlink: {exc}") + + async with acp_provider._hold_workspace_effort_overlay_fence(alias_work_dir, "window", 0.1): + async with acp_provider._hold_workspace_effort_overlay_fence( + real_work_dir, "write", 0.01 + ) as acquired: + assert acquired is False + + @pytest.mark.asyncio + async def test_fence_key_is_resolved_before_the_registry_lookup(self, tmp_path, monkeypatch): + original_to_thread = asyncio.to_thread + + async def yielding_to_thread(function, *args, **kwargs): + if function is acp_provider.workspace_cli_settings_fence_key: + await asyncio.sleep(0) + return await original_to_thread(function, *args, **kwargs) + + monkeypatch.setattr(acp_provider.asyncio, "to_thread", yielding_to_thread) + original_acquire = acp_provider._WorkspaceEffortOverlayFence.acquire + + async def nonwaiting_write_acquire(fence, mode, timeout): + if mode == "write" and fence._write_held: + return False + return await original_acquire(fence, mode, timeout) + + monkeypatch.setattr( + acp_provider._WorkspaceEffortOverlayFence, "acquire", nonwaiting_write_acquire + ) + release = asyncio.Event() + outcomes_ready = asyncio.Event() + outcomes: list[bool] = [] + + async def hold_until_released(): + async with acp_provider._hold_workspace_effort_overlay_fence( + tmp_path, "write", self._UNBLOCKED_DEADLINE_SECS + ) as acquired: + outcomes.append(acquired) + if len(outcomes) == 2: + outcomes_ready.set() + if acquired: + await _await_test(release.wait(), "release") + + first = asyncio.create_task(hold_until_released()) + second = asyncio.create_task(hold_until_released()) + await asyncio.wait_for(outcomes_ready.wait(), self._UNBLOCKED_DEADLINE_SECS) + release.set() + await _await_test(asyncio.gather(first, second), "task cleanup") + + assert sorted(outcomes) == [False, True] + + @pytest.mark.asyncio + async def test_fence_resolution_failure_fails_the_start_it_cannot_key( + self, tmp_path, monkeypatch + ): + provider = self._provider(tmp_path) + original_realpath = os.path.realpath + + def failing_realpath(_path): + raise OSError("unavailable mount") + + monkeypatch.setattr(acp_provider.os.path, "realpath", failing_realpath) + with pytest.raises(OSError, match="unavailable mount"): + await provider.start() + + monkeypatch.setattr(acp_provider.os.path, "realpath", original_realpath) + async with acp_provider._hold_workspace_effort_overlay_fence( + tmp_path, "write", self._UNBLOCKED_DEADLINE_SECS + ) as acquired: + assert acquired is True + + @pytest.mark.asyncio + async def test_fence_resolution_refusal_uses_the_named_settings_fallback( + self, tmp_path, monkeypatch + ): + work_dir = tmp_path / "work" + work_dir.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + platform_compat.symlink_or_junction(outside, work_dir / ".kiro") + provider = self._provider(work_dir) + monkeypatch.setattr(provider, "_apply_effort_overlay", lambda: True) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + # The managed-agent shadow check reads /.kiro/agents through the link on + # purpose: kiro-cli loads that directory, so the check has to see what it would + # load. This test is about the settings folder and the fence, so it stubs the + # check out rather than recording its reads. + monkeypatch.setattr( + "kiro_crew.acp.managed_agent_shadow.managed_agent_shadow_refusal", + lambda agent, work_dir: None, + ) + + work_dir_real = os.path.realpath(work_dir) + outside_root = os.path.normcase(os.path.abspath(outside)) + calls = [] + + def record(name, original): + def wrapped(path, *args, **kwargs): + try: + text = os.fsdecode(os.fspath(path)) + except TypeError: + text = repr(path) + calls.append((name, text)) + return original(path, *args, **kwargs) + + return wrapped + + with monkeypatch.context() as probes: + probes.setattr(os, "lstat", record("lstat", os.lstat)) + probes.setattr(os, "stat", record("stat", os.stat)) + probes.setattr(os, "open", record("open", os.open)) + probes.setattr(os, "readlink", record("readlink", os.readlink)) + probes.setattr(os, "scandir", record("scandir", os.scandir)) + probes.setattr(os, "listdir", record("listdir", os.listdir)) + probes.setattr(os.path, "realpath", record("realpath", os.path.realpath)) + + assert acp_provider.workspace_cli_settings_fence_key(work_dir) == os.path.join( + work_dir_real, ".kiro", "settings" + ) + await provider.start() + + traversed = [] + for operation, raw in calls: + if not os.path.isabs(raw): + continue + candidate = os.path.normcase(os.path.abspath(raw)) + if candidate == outside_root or candidate.startswith(outside_root + os.path.sep): + traversed.append((operation, raw)) + assert traversed == [] + provider._start_kiro_runtime.assert_awaited_once() + + @pytest.mark.asyncio + async def test_cancelled_fence_resolution_acquires_nothing(self, tmp_path, monkeypatch): + provider = self._provider(tmp_path) + original_fence_key = acp_provider.workspace_cli_settings_fence_key + resolution_started = threading.Event() + release_resolution = threading.Event() + + def blocked_fence_key(path): + resolution_started.set() + assert release_resolution.wait(self._UNBLOCKED_DEADLINE_SECS) + return original_fence_key(path) + + monkeypatch.setattr(acp_provider, "workspace_cli_settings_fence_key", blocked_fence_key) + start_task = asyncio.create_task(provider.start()) + await async_wait_until( + resolution_started.is_set, + timeout=self._UNBLOCKED_DEADLINE_SECS, + describe=lambda: "resolution_started was never set", + ) + start_task.cancel() + release_resolution.set() + with pytest.raises(asyncio.CancelledError): + await _await_test(start_task, "start_task") + + monkeypatch.setattr(acp_provider, "workspace_cli_settings_fence_key", original_fence_key) + async with acp_provider._hold_workspace_effort_overlay_fence( + tmp_path, "write", self._UNBLOCKED_DEADLINE_SECS + ) as acquired: + assert acquired is True + + @pytest.mark.asyncio + async def test_default_spawn_waits_for_level_projection_then_reads_a_cleared_overlay( + self, tmp_path, monkeypatch + ): + level = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + default = self._provider(tmp_path) + worker_started = threading.Event() + release_worker = threading.Event() + default_entered = asyncio.Event() + snapshot_at_runtime = {} + + def blocked_level_projection(): + worker_started.set() + assert release_worker.wait(timeout=5) + return _write_cli_overlay(tmp_path, self._MODEL, "max") + + async def snapshot_runtime_start(): + snapshot_at_runtime.update(_read_cli_overlay(tmp_path)) + default_entered.set() + + monkeypatch.setattr(level, "_apply_effort_overlay", blocked_level_projection) + monkeypatch.setattr(level, "_start_kiro_runtime", AsyncMock()) + monkeypatch.setattr(default, "_start_kiro_runtime", snapshot_runtime_start) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_TIMEOUT_SECS", 0.01) + monkeypatch.setattr(acp_provider, "CLI_SETTINGS_LOCK_ACTION_TIMEOUT_SECS", 0.02) + monkeypatch.setattr( + acp_provider, + "_WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS", + 0.5, + raising=False, + ) + level_task = asyncio.create_task(level.start()) + await async_wait_until( + worker_started.is_set, describe=lambda: "worker_started was never set" + ) + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + directory_wait = _observe_directory_wait(monkeypatch) + default_task = asyncio.create_task(default.start()) + await _await_test(directory_wait.wait(), "the Default start waiting for the directory") + assert fence._write_held + assert not default_entered.is_set() + release_worker.set() + await _await_test(level_task, "level_task") + await _await_test(default_task, "default_task") + assert snapshot_at_runtime == {} + + @pytest.mark.asyncio + async def test_pending_window_blocks_a_write_after_the_held_write_releases(self, tmp_path): + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + assert await fence.acquire("write", 0.1) is True + window_task = asyncio.create_task(fence.acquire("window", self._UNBLOCKED_DEADLINE_SECS)) + await asyncio.sleep(0) + assert fence._pending_windows == 1 + + assert await fence.acquire("write", 0.01) is False + + fence.release("write") + assert await _await_test(window_task, "window_task") is True + fence.release("window") + + @pytest.mark.asyncio + async def test_a_write_arriving_while_a_default_start_waits_lands_before_it( + self, tmp_path, monkeypatch + ): + # A start that is only waiting for its directory holds nothing off, so a + # write arriving then goes first. Once the start holds its window, a later + # write waits for its runtime start. + holder = self._provider(tmp_path, effort_per_model={self._MODEL: "max"}) + default = self._provider(tmp_path) + writer = self._provider(tmp_path) + writer._client.send_command = AsyncMock(return_value="") + late_writer = self._provider(tmp_path) + late_writer._client.send_command = AsyncMock(return_value="") + projection_started = threading.Event() + release_projection = threading.Event() + default_started = asyncio.Event() + release_default = asyncio.Event() + write_landed_when_default_started: list[bool] = [] + + def hold_level_projection(): + projection_started.set() + assert release_projection.wait(timeout=5) + return True + + async def hold_default_runtime_start(): + write_landed_when_default_started.append(write_acquired.is_set()) + default_started.set() + await _await_test(release_default.wait(), "release_default") + + monkeypatch.setattr(holder, "_apply_effort_overlay", hold_level_projection) + monkeypatch.setattr(holder, "_start_kiro_runtime", AsyncMock()) + monkeypatch.setattr(default, "_start_kiro_runtime", hold_default_runtime_start) + holder_task = asyncio.create_task(holder.start()) + await async_wait_until( + projection_started.is_set, describe=lambda: "projection_started was never set" + ) + write_waiting, write_acquired = self._observe_write_fence_acquire(monkeypatch) + directory_wait = _observe_directory_wait(monkeypatch) + default_task = asyncio.create_task(default.start()) + await _await_test(directory_wait.wait(), "the Default start waiting for the directory") + writer_task = asyncio.create_task(writer.change_effort("high")) + await _await_test(write_waiting.wait(), "the write reaching the fence") + release_projection.set() + await _await_test(holder_task, "holder_task") + await _await_test(default_started.wait(), "default_started") + assert write_landed_when_default_started == [True], "the waiting start held off the write" + assert await _await_test(writer_task, "writer_task") is True + + late_waiting, late_acquired = self._observe_write_fence_acquire(monkeypatch) + late_task = asyncio.create_task(late_writer.change_effort("high")) + await _await_test(late_waiting.wait(), "the late write reaching the fence") + assert not late_acquired.is_set(), "the late write acquired through a held window" + release_default.set() + await _await_test(default_task, "default_task") + assert await _await_test(late_task, "late_task") is True + + @pytest.mark.asyncio + async def test_second_cancellation_while_waiting_for_overlay_worker_releases_window( + self, tmp_path, monkeypatch + ): + provider = self._provider(tmp_path) + sibling = self._provider(tmp_path) + sibling._client.send_command = AsyncMock(return_value="") + worker_started = threading.Event() + release_worker = threading.Event() + + def blocked_overlay(): + worker_started.set() + assert release_worker.wait(timeout=5) + return True + + monkeypatch.setattr(provider, "_apply_effort_overlay", blocked_overlay) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + start_task = asyncio.create_task(provider.start()) + await async_wait_until( + worker_started.is_set, describe=lambda: "worker_started was never set" + ) + start_task.cancel() + await asyncio.sleep(0) + start_task.cancel() + release_worker.set() + with pytest.raises(asyncio.CancelledError): + await _await_test(start_task, "start_task") + + assert ( + await asyncio.wait_for(sibling.change_effort("max"), self._UNBLOCKED_DEADLINE_SECS) + is True + ) diff --git a/test/test_effort_spawn_fence_explicit_default.py b/test/test_effort_spawn_fence_explicit_default.py new file mode 100644 index 00000000000..d470547e4b7 --- /dev/null +++ b/test/test_effort_spawn_fence_explicit_default.py @@ -0,0 +1,1118 @@ +"""cli.json overlay tests: a pending explicit Default at the next cold start. + +A pending explicit Default makes startup remove the target model's entry once. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import threading +from contextlib import contextmanager, nullcontext +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from effort_helpers import _read_cli_overlay +from test_effort import _await_test + +from conftest import requires_symlinks +from kiro_crew.acp.session_provider import AcpSessionProvider +from kiro_crew.acp.types import ACP_BACKEND_KAS, ACP_BACKEND_KIRO +from kiro_crew.config import paths as config_paths +from kiro_crew.effort import effort_settings_key +from kiro_crew.providers import acp as acp_provider +from kiro_crew.providers import acp_cli_overlay +from kiro_crew.providers.acp import _KIROCREW_EFFORT_OWNED_KEY +from kiro_crew.testing.wait import async_wait_until + +pytestmark = pytest.mark.usefixtures("no_zero_tool_check") + + +class TestExplicitDefaultColdStart: + """A pending explicit Default makes startup remove the target model's entry once. + + The pending projection does to ``cli.json`` what ``clear_effort(owned_only=False)`` + does on a live provider with the same backend, model and resolved level, and + the provider reports it applied only when that projection succeeded. + """ + + _MODEL = "claude-opus-4.7" + + def _provider(self, work_dir, *, model=None, **kwargs): + return acp_provider.AcpProvider( + acp_backend=kwargs.pop("acp_backend", ACP_BACKEND_KIRO), + work_dir=work_dir, + model=model or self._MODEL, + **kwargs, + ) + + @staticmethod + def _write(work_dir, data): + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True, exist_ok=True) + cli_json = settings / "cli.json" + cli_json.write_text(json.dumps(data), encoding="utf-8") + return cli_json + + def _operator_entry(self, work_dir, level, model=None): + model = model or self._MODEL + entry = {effort_settings_key(model): {"effort": level}} + return self._write(work_dir, {"chat.modelDefaults": {model: entry}}) + + @staticmethod + def _seed_sessions_dir(tmp_path, monkeypatch, session_ids): + """Redirect the kiro-cli transcript directory and seed a pair per session id.""" + sessions = tmp_path / "kiro-sessions" + sessions.mkdir() + monkeypatch.setattr(config_paths, "_sessions_dir_override", lambda: sessions) + for sid in session_ids: + for suffix in (".json", ".jsonl"): + (sessions / f"{sid}{suffix}").write_text("{}", encoding="utf-8") + return sessions + + @staticmethod + def _session_files_present(sessions, sid): + return [(sessions / f"{sid}{suffix}").exists() for suffix in (".json", ".jsonl")] + + @staticmethod + def _live_client( + work_dir, + model, + session_id, + *, + push_error=None, + backend=ACP_BACKEND_KIRO, + model_pin_refused="", + requested_model="auto", + ): + alive = [True] + + async def kill(**_kwargs): + alive[0] = False + + handle = SimpleNamespace( + session_id=session_id, + model=requested_model, + served_model=model, + model_pin_refused=model_pin_refused, + memory_mode="persistent", + keep_transcript=False, + _cancel_hook_tasks=MagicMock(), + destroy=AsyncMock(), + send_command=AsyncMock( + side_effect=push_error, + return_value="" if push_error is None else None, + ), + ) + runtime = SimpleNamespace( + acp_backend=backend, + _work_dir=work_dir, + is_alive=lambda: alive[0], + kill=AsyncMock(side_effect=kill), + work_scratch_dir=None, + ) + return AcpSessionProvider(handle, runtime, owns_runtime=True), handle, runtime + + def _stub_client_start(self, provider, monkeypatch, model, snapshots): + """Stub the AcpClient start path, the one a harness off the shared runtime takes.""" + client = provider._client + + async def ensure_ready(): + snapshots.append(_read_cli_overlay(client._work_dir)) + client._resolved_model_id = model + + monkeypatch.setattr(client, "ensure_ready", ensure_ready) + monkeypatch.setattr(client, "confirm_member_projection", AsyncMock()) + + def _stub_runtime_starts( + self, + provider, + monkeypatch, + models, + snapshots, + clients, + resumed_flags=None, + replay_flags=None, + replay_flag_snapshots=None, + backend=ACP_BACKEND_KIRO, + teardown_error=None, + runtime_stays_alive=False, + refused_models=None, + ): + async def start_runtime(): + snapshots.append(_read_cli_overlay(provider._client._work_dir)) + index = len(snapshots) - 1 + if replay_flag_snapshots is not None: + replay_flag_snapshots.append( + ( + provider._history_replay_needed, + provider._defer_replay_sid_promotion, + ) + ) + if replay_flags is not None and replay_flags[index] is not None: + ( + provider._history_replay_needed, + provider._defer_replay_sid_promotion, + ) = replay_flags[index] + if index: + assert clients[-1][2].is_alive() is False + refused_model = refused_models[index] if refused_models is not None else "" + served_as_pinned = not refused_model and provider._spawn_effort_model != "auto" + client, handle, runtime = self._live_client( + provider._client._work_dir, + models[index], + f"session-{index + 1}", + backend=backend, + model_pin_refused=refused_model, + requested_model=models[index] if served_as_pinned else "auto", + ) + if index == 0 and teardown_error is not None: + client.shutdown = AsyncMock(side_effect=teardown_error) + if index == 0 and runtime_stays_alive: + runtime.kill = AsyncMock() + if resumed_flags is not None: + client.resumed = resumed_flags[index] + clients.append((client, handle, runtime, provider._client._resume_session_id)) + provider._client = client + + monkeypatch.setattr(provider, "_start_kiro_runtime", start_runtime) + monkeypatch.setattr(provider, "_note_zero_tool_servers", AsyncMock()) + + @pytest.mark.asyncio + async def test_unpinned_start_restarts_after_clearing_only_the_served_model( + self, tmp_path, monkeypatch + ): + other_model = "claude-sonnet-4.6" + self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: {"output_config": {"effort": "max"}}, + other_model: {"output_config": {"effort": "high"}}, + } + }, + ) + provider = self._provider(tmp_path, model="auto") + sessions = self._seed_sessions_dir(tmp_path, monkeypatch, ["session-1", "session-2"]) + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, monkeypatch, [self._MODEL, self._MODEL], snapshots, clients + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert snapshots == [ + {self._MODEL: "max", other_model: "high"}, + {other_model: "high"}, + ] + assert _read_cli_overlay(tmp_path) == {other_model: "high"} + assert len(clients) == 2 + first_client, first_handle, first_runtime, _ = clients[0] + assert provider._client is clients[1][0] + first_runtime.kill.assert_awaited_once_with(expected=True, reason="provider shutdown") + first_handle.destroy.assert_not_awaited() + assert clients[1][3] == "" + assert self._session_files_present(sessions, "session-1") == [False, False] + assert self._session_files_present(sessions, "session-2") == [True, True] + assert provider.explicit_effort_default_applied is True + assert provider._explicit_effort_default_pending is False + + @pytest.mark.asyncio + async def test_pinned_start_finishes_default_for_the_substituted_served_model( + self, tmp_path, monkeypatch + ): + served_model = "claude-sonnet-4.6" + self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: {"output_config": {"effort": "max"}}, + served_model: {"output_config": {"effort": "high"}}, + } + }, + ) + provider = self._provider(tmp_path) + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, + monkeypatch, + [served_model, served_model], + snapshots, + clients, + refused_models=[self._MODEL, self._MODEL], + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert snapshots == [{served_model: "high"}, {}] + assert _read_cli_overlay(tmp_path) == {} + assert len(clients) == 2 + assert clients[0][2].is_alive() is False + assert provider.explicit_effort_default_applied is True + assert provider._explicit_effort_default_pending is False + + @pytest.mark.asyncio + async def test_pinned_start_finishes_default_for_a_differently_spelled_served_model( + self, tmp_path, monkeypatch + ): + served_model = self._MODEL.upper() + self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: {"output_config": {"effort": "max"}}, + served_model: {"output_config": {"effort": "high"}}, + } + }, + ) + provider = self._provider(tmp_path) + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, + monkeypatch, + [served_model, served_model], + snapshots, + clients, + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert snapshots == [{served_model: "high"}, {}] + assert _read_cli_overlay(tmp_path) == {} + assert len(clients) == 2 + assert clients[0][2].is_alive() is False + assert provider.explicit_effort_default_applied is True + assert provider._explicit_effort_default_pending is False + + @pytest.mark.asyncio + async def test_unpinned_restart_keeps_the_first_session_that_is_the_resume_target( + self, tmp_path, monkeypatch + ): + """A first session the chat can still resume is never deleted by the restart.""" + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + provider._client._resume_session_id = "session-1" + sessions = self._seed_sessions_dir(tmp_path, monkeypatch, ["session-1", "session-2"]) + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, monkeypatch, [self._MODEL, self._MODEL], snapshots, clients + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert len(clients) == 2 + assert clients[1][3] == "session-1" + assert self._session_files_present(sessions, "session-1") == [True, True] + assert self._session_files_present(sessions, "session-2") == [True, True] + assert provider.explicit_effort_default_applied is True + + @pytest.mark.parametrize("backend", [ACP_BACKEND_KAS, "claude"], ids=["kas", "claude"]) + @pytest.mark.asyncio + async def test_unpinned_default_leaves_the_overlay_alone_off_the_reader_set( + self, tmp_path, monkeypatch, backend + ): + # A harness that never opens cli.json must not snapshot or rewrite the + # entry a kiro-cli chat in the same work dir spawns on, and must not pay + # a restart for a file its engine does not read. Its Default is spent at + # the start, as a pinned start spends it. + cli_json = self._operator_entry(tmp_path, "max") + before = cli_json.read_bytes() + provider = self._provider(tmp_path, model="auto", acp_backend=backend) + snapshots = [] + clients = [] + if backend == ACP_BACKEND_KAS: + # Two models are staged so a start that wrongly restarts reaches its + # own assertions rather than running the stub out of answers. + self._stub_runtime_starts( + provider, + monkeypatch, + [self._MODEL, self._MODEL], + snapshots, + clients, + backend=backend, + ) + else: + self._stub_client_start(provider, monkeypatch, self._MODEL, snapshots) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert provider.served_model == self._MODEL + assert len(snapshots) == 1 + assert cli_json.read_bytes() == before + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + assert provider.explicit_effort_default_applied is True + assert provider._explicit_effort_default_pending is False + + @pytest.mark.asyncio + async def test_unpinned_restart_repeats_the_first_start_resume_decision( + self, tmp_path, monkeypatch + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + provider._client._resume_session_id = "persisted-session" + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, monkeypatch, [self._MODEL, self._MODEL], snapshots, clients + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert len(clients) == 2 + assert [client[3] for client in clients] == [ + "persisted-session", + "persisted-session", + ] + assert all(client[0].resumed is False for client in clients) + assert provider.explicit_effort_default_applied is True + assert provider._explicit_effort_default_pending is False + + @pytest.mark.asyncio + async def test_restart_uses_only_the_replacement_native_load_replay_flags( + self, tmp_path, monkeypatch, caplog + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + replay_flag_snapshots = [] + self._stub_runtime_starts( + provider, + monkeypatch, + [self._MODEL, self._MODEL], + snapshots, + clients, + resumed_flags=[False, True], + replay_flags=[(True, True), None], + replay_flag_snapshots=replay_flag_snapshots, + ) + provider.arm_explicit_effort_default(nullcontext) + + with caplog.at_level(logging.INFO, logger=acp_provider.__name__): + await provider.start() + + assert replay_flag_snapshots == [(False, False), (False, False)] + assert provider._history_replay_needed is False + assert provider._defer_replay_sid_promotion is False + assert ( + sum( + "restored with session/load keeps the level" in record.getMessage() + for record in caplog.records + ) + == 1 + ) + + @pytest.mark.asyncio + async def test_restart_reapplies_tool_search_replay_flags_from_replacement_start( + self, tmp_path, monkeypatch + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + replay_flag_snapshots = [] + self._stub_runtime_starts( + provider, + monkeypatch, + [self._MODEL, self._MODEL], + snapshots, + clients, + replay_flags=[(True, True), (True, True)], + replay_flag_snapshots=replay_flag_snapshots, + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert replay_flag_snapshots == [(False, False), (False, False)] + assert provider._history_replay_needed is True + assert provider._defer_replay_sid_promotion is True + + @pytest.mark.asyncio + async def test_restart_preserves_start_entry_replay_state_only(self, tmp_path, monkeypatch): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + provider._history_replay_needed = True + snapshots = [] + clients = [] + replay_flag_snapshots = [] + self._stub_runtime_starts( + provider, + monkeypatch, + [self._MODEL, self._MODEL], + snapshots, + clients, + replay_flags=[(True, True), None], + replay_flag_snapshots=replay_flag_snapshots, + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert replay_flag_snapshots == [(True, False), (True, False)] + assert provider._history_replay_needed is True + assert provider._defer_replay_sid_promotion is False + + @pytest.mark.asyncio + async def test_unpinned_native_loaded_session_is_not_restarted( + self, tmp_path, monkeypatch, caplog + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, + monkeypatch, + [self._MODEL, self._MODEL], + snapshots, + clients, + resumed_flags=[True, False], + ) + provider.arm_explicit_effort_default(nullcontext) + + with caplog.at_level(logging.INFO, logger=acp_provider.__name__): + await provider.start() + + assert snapshots == [{self._MODEL: "max"}] + assert _read_cli_overlay(tmp_path) == {} + assert len(clients) == 1 + assert provider._client is clients[0][0] + assert clients[0][2].is_alive() is True + clients[0][2].kill.assert_not_awaited() + assert provider.explicit_effort_default_applied is True + assert provider._explicit_effort_default_pending is False + assert ( + sum( + "restored with session/load keeps the level" in record.getMessage() + for record in caplog.records + ) + == 1 + ) + + @pytest.mark.asyncio + async def test_unpinned_start_with_no_served_model_entry_changes_nothing( + self, tmp_path, monkeypatch + ): + other_model = "claude-sonnet-4.6" + self._operator_entry(tmp_path, "high", model=other_model) + before = (tmp_path / ".kiro" / "settings" / "cli.json").read_bytes() + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + self._stub_runtime_starts(provider, monkeypatch, [self._MODEL], snapshots, clients) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert len(clients) == 1 + assert snapshots == [{other_model: "high"}] + assert (tmp_path / ".kiro" / "settings" / "cli.json").read_bytes() == before + assert provider.explicit_effort_default_applied is True + + @pytest.mark.parametrize( + ("model", "inactive_key"), + [ + ("claude-opus-4.7", "reasoning"), + ("gpt-5.6-sol", "output_config"), + ], + ids=["claude-reasoning", "gpt-output-config"], + ) + @pytest.mark.asyncio + async def test_unpinned_start_ignores_effort_under_inactive_family_key( + self, tmp_path, monkeypatch, model, inactive_key + ): + cli_json = self._write( + tmp_path, + { + "chat.modelDefaults": { + model: {inactive_key: {"effort": "max"}}, + } + }, + ) + before = cli_json.read_bytes() + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + self._stub_runtime_starts(provider, monkeypatch, [model, model], snapshots, clients) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert len(clients) == 1 + assert cli_json.read_bytes() == before + assert provider.explicit_effort_default_applied is True + + def test_overlay_snapshot_counts_only_each_models_active_effort_key(self, tmp_path): + claude_model = "claude-opus-4.7" + gpt_model = "gpt-5.6-sol" + self._write( + tmp_path, + { + "chat.modelDefaults": { + claude_model: {"output_config": {"effort": "max"}}, + gpt_model: {"output_config": {"effort": "high"}}, + } + }, + ) + + assert acp_cli_overlay._cli_overlay_effort_models(tmp_path) == {claude_model} + + @pytest.mark.asyncio + async def test_unpinned_start_does_not_remove_peer_entry_written_after_spawn( + self, tmp_path, monkeypatch + ): + other_model = "claude-sonnet-4.6" + self._operator_entry(tmp_path, "high", model=other_model) + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + + async def start_runtime(): + snapshots.append(_read_cli_overlay(provider._client._work_dir)) + self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: {"output_config": {"effort": "max"}}, + other_model: {"output_config": {"effort": "high"}}, + } + }, + ) + client, handle, runtime = self._live_client( + provider._client._work_dir, self._MODEL, "session-1" + ) + clients.append((client, handle, runtime)) + provider._client = client + + monkeypatch.setattr(provider, "_start_kiro_runtime", start_runtime) + monkeypatch.setattr(provider, "_note_zero_tool_servers", AsyncMock()) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert snapshots == [{other_model: "high"}] + assert len(clients) == 1 + assert _read_cli_overlay(tmp_path) == { + self._MODEL: "max", + other_model: "high", + } + assert provider.explicit_effort_default_applied is True + + @pytest.mark.asyncio + async def test_unpinned_start_with_non_effort_model_keeps_its_entry( + self, tmp_path, monkeypatch + ): + model = "claude-haiku-4.5" + cli_json = self._operator_entry(tmp_path, "max", model=model) + before = cli_json.read_bytes() + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + self._stub_runtime_starts(provider, monkeypatch, [model], snapshots, clients) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert len(clients) == 1 + assert snapshots == [{model: "max"}] + assert cli_json.read_bytes() == before + assert provider.explicit_effort_default_applied is True + + @pytest.mark.asyncio + async def test_unpinned_start_keeps_default_pending_when_served_model_is_unknown( + self, tmp_path, monkeypatch, caplog + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + self._stub_runtime_starts(provider, monkeypatch, [""], snapshots, clients) + provider.arm_explicit_effort_default(nullcontext) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + await provider.start() + + assert len(clients) == 1 + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + assert provider.explicit_effort_default_applied is False + assert provider._explicit_effort_default_pending is True + assert sum("served model is unknown" in r.getMessage() for r in caplog.records) == 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize("push_fails", [False, True], ids=["push-succeeds", "push-fails"]) + async def test_unpinned_start_projects_workspace_default_and_requires_live_push( + self, tmp_path, monkeypatch, push_fails + ): + other_model = "claude-sonnet-4.6" + self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: {"output_config": {"effort": "max"}}, + other_model: {"output_config": {"effort": "high"}}, + } + }, + ) + provider = self._provider(tmp_path, model="auto", effort_defaults={self._MODEL: "low"}) + snapshots = [] + clients = [] + + async def start_runtime(): + snapshots.append(_read_cli_overlay(provider._client._work_dir)) + error = RuntimeError("push refused") if push_fails else None + client, handle, runtime = self._live_client( + provider._client._work_dir, + self._MODEL, + "session-1", + push_error=error, + ) + clients.append((client, handle, runtime)) + provider._client = client + + monkeypatch.setattr(provider, "_start_kiro_runtime", start_runtime) + monkeypatch.setattr(provider, "_note_zero_tool_servers", AsyncMock()) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert snapshots == [{self._MODEL: "max", other_model: "high"}] + assert _read_cli_overlay(tmp_path) == { + self._MODEL: "low", + other_model: "high", + } + clients[0][1].send_command.assert_awaited_once_with("/effort", {"level": "low"}) + assert provider.explicit_effort_default_applied is not push_fails + assert provider._explicit_effort_default_pending is push_fails + + @pytest.mark.asyncio + async def test_unpinned_start_keeps_default_pending_when_rewrite_is_refused( + self, tmp_path, monkeypatch, caplog + ): + other_model = "claude-sonnet-4.6" + self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: {"output_config": {"effort": "max"}}, + other_model: {"output_config": {"effort": "high"}}, + } + }, + ) + provider = self._provider(tmp_path, model="auto", effort_defaults={self._MODEL: "low"}) + snapshots = [] + clients = [] + self._stub_runtime_starts(provider, monkeypatch, [self._MODEL], snapshots, clients) + monkeypatch.setattr( + acp_provider, + "_write_cli_overlay", + MagicMock(side_effect=OSError("version guard refused the write")), + ) + provider.arm_explicit_effort_default(nullcontext) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + await provider.start() + + assert len(clients) == 1 + assert _read_cli_overlay(tmp_path) == { + self._MODEL: "max", + other_model: "high", + } + assert provider.explicit_effort_default_applied is False + assert provider._explicit_effort_default_pending is True + assert sum("overlay rewrite failed" in r.getMessage() for r in caplog.records) == 1 + + @pytest.mark.asyncio + async def test_unpinned_restart_stops_after_second_model_has_its_own_entry( + self, tmp_path, monkeypatch, caplog + ): + second_model = "claude-sonnet-4.6" + self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: {"output_config": {"effort": "max"}}, + second_model: {"output_config": {"effort": "high"}}, + } + }, + ) + provider = self._provider(tmp_path, model="auto") + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, monkeypatch, [self._MODEL, second_model], snapshots, clients + ) + provider.arm_explicit_effort_default(nullcontext) + + with caplog.at_level(logging.WARNING, logger=acp_provider.__name__): + with pytest.raises( + RuntimeError, + match="replacement runtime still reads its served model's effort entry", + ): + await provider.start() + + assert snapshots == [ + {self._MODEL: "max", second_model: "high"}, + {second_model: "high"}, + ] + assert len(clients) == 2 + assert provider._client is clients[1][0] + assert _read_cli_overlay(tmp_path) == {second_model: "high"} + assert provider.explicit_effort_default_applied is False + assert provider._explicit_effort_default_pending is True + assert sum("no third runtime" in r.getMessage() for r in caplog.records) == 1 + + @pytest.mark.asyncio + async def test_unpinned_restart_fails_when_first_client_shutdown_raises( + self, tmp_path, monkeypatch + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + sessions = self._seed_sessions_dir(tmp_path, monkeypatch, ["session-1"]) + snapshots = [] + clients = [] + shutdown_error = RuntimeError("shutdown failed") + self._stub_runtime_starts( + provider, + monkeypatch, + [self._MODEL, self._MODEL], + snapshots, + clients, + teardown_error=shutdown_error, + ) + provider.arm_explicit_effort_default(nullcontext) + + with pytest.raises(RuntimeError, match="shutting down the first runtime failed") as exc: + await provider.start() + + assert exc.value.__cause__ is shutdown_error + assert snapshots == [{self._MODEL: "max"}] + assert provider._client is clients[0][0] + assert provider.explicit_effort_default_applied is False + assert provider._explicit_effort_default_pending is True + assert self._session_files_present(sessions, "session-1") == [True, True] + + @pytest.mark.asyncio + async def test_unpinned_restart_fails_when_first_runtime_remains_alive( + self, tmp_path, monkeypatch + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + sessions = self._seed_sessions_dir(tmp_path, monkeypatch, ["session-1"]) + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, + monkeypatch, + [self._MODEL, self._MODEL], + snapshots, + clients, + runtime_stays_alive=True, + ) + provider.arm_explicit_effort_default(nullcontext) + + with pytest.raises(RuntimeError, match="the first runtime did not stop"): + await provider.start() + + assert snapshots == [{self._MODEL: "max"}] + assert provider._client is clients[0][0] + assert provider.explicit_effort_default_applied is False + assert provider._explicit_effort_default_pending is True + assert self._session_files_present(sessions, "session-1") == [True, True] + + @pytest.mark.asyncio + async def test_unpinned_restart_failure_raises_without_restoring_dead_client( + self, tmp_path, monkeypatch + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, model="auto") + placeholder_client = provider._client + snapshots = [] + clients = [] + + async def start_runtime(): + snapshots.append(_read_cli_overlay(provider._client._work_dir)) + if len(snapshots) == 2: + raise RuntimeError("replacement start failed") + client, handle, runtime = self._live_client( + provider._client._work_dir, self._MODEL, "session-1" + ) + clients.append((client, handle, runtime)) + provider._client = client + + monkeypatch.setattr(provider, "_start_kiro_runtime", start_runtime) + monkeypatch.setattr(provider, "_note_zero_tool_servers", AsyncMock()) + provider.arm_explicit_effort_default(nullcontext) + + with pytest.raises(RuntimeError, match="replacement start failed"): + await provider.start() + + assert snapshots == [{self._MODEL: "max"}, {}] + assert provider._client is placeholder_client + assert clients[0][2].is_alive() is False + assert provider.explicit_effort_default_applied is False + assert provider._explicit_effort_default_pending is True + + @pytest.mark.asyncio + async def test_start_removes_an_operator_entry_once_when_explicit_default_is_pending( + self, tmp_path, monkeypatch + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path) + snapshots = [] + clients = [] + self._stub_runtime_starts( + provider, monkeypatch, [self._MODEL, self._MODEL], snapshots, clients + ) + provider.arm_explicit_effort_default(nullcontext) + + await provider.start() + + assert _read_cli_overlay(tmp_path) == {} + assert provider.explicit_effort_default_applied is True + + # A later spawn of the same provider (a resume, a model swap) projects + # with the ownership guard again. + await provider._client.shutdown() + self._operator_entry(tmp_path, "high") + await provider.start() + assert _read_cli_overlay(tmp_path) == {self._MODEL: "high"} + + @pytest.mark.asyncio + async def test_start_keeps_an_operator_entry_without_explicit_default( + self, tmp_path, monkeypatch + ): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + + await provider.start() + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + assert provider.explicit_effort_default_applied is False + + @pytest.mark.asyncio + @requires_symlinks + async def test_a_refused_file_leaves_the_default_pending_for_the_next_start( + self, tmp_path, monkeypatch + ): + # A linked cli.json is never rewritten, so the projection fails and the + # intent is not spent. The next start that can rewrite the file applies it. + target = tmp_path / "outside.json" + outside = {"chat.modelDefaults": {self._MODEL: {"output_config": {"effort": "max"}}}} + target.write_text(json.dumps(outside), encoding="utf-8") + settings = tmp_path / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + try: + cli_json.symlink_to(target) + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + provider = self._provider(tmp_path) + provider.arm_explicit_effort_default(nullcontext) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + + await provider.start() + + assert provider.explicit_effort_default_applied is False + assert json.loads(target.read_text(encoding="utf-8")) == outside + + cli_json.unlink() + self._operator_entry(tmp_path, "max") + snapshots = [] + clients = [] + self._stub_runtime_starts(provider, monkeypatch, [self._MODEL], snapshots, clients) + await provider.start() + + assert provider.explicit_effort_default_applied is True + assert _read_cli_overlay(tmp_path) == {} + + def test_each_projection_resets_its_receipt_before_the_write(self, tmp_path, monkeypatch): + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path) + provider.arm_explicit_effort_default(nullcontext) + + provider._project_explicit_effort_default() + assert provider._explicit_effort_default_projected is True + + monkeypatch.setattr(provider, "_apply_effort_overlay", MagicMock(return_value=False)) + provider._project_explicit_effort_default() + + assert provider._explicit_effort_default_projected is False + assert provider._explicit_effort_default_pending is True + assert provider.explicit_effort_default_applied is False + + @pytest.mark.asyncio + async def test_the_rewrite_runs_inside_its_fence_and_the_spawn_after_it( + self, tmp_path, monkeypatch + ): + # The fence refuses warm-runtime claims while it is up and dates the + # rewrite as it comes down: the write must be inside it, and the rest of + # the start, which can take seconds, outside it. + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path) + events: list[str] = [] + + @contextmanager + def fence(): + events.append("fence up") + try: + yield + finally: + events.append("fence down") + + real_atomic_write = acp_provider.atomic_write + + def record_write(path, contents, **kwargs): + events.append("write") + real_atomic_write(path, contents, **kwargs) + + monkeypatch.setattr(acp_provider, "atomic_write", record_write) + monkeypatch.setattr( + provider, "_start_kiro_runtime", AsyncMock(side_effect=lambda: events.append("spawn")) + ) + provider.arm_explicit_effort_default(fence) + + await provider.start() + + assert events == ["fence up", "write", "fence down", "spawn"] + assert _read_cli_overlay(tmp_path) == {} + + @pytest.mark.asyncio + async def test_a_start_cancelled_during_the_rewrite_still_knows_it_ran( + self, tmp_path, monkeypatch + ): + # A cold start cancelled here arms the session's flag again unless the + # provider reports the replacement ran, and a flag armed after the + # removal removes the next level anyone projects for this model. + self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + worker_started = threading.Event() + release_worker = threading.Event() + worker_finished = threading.Event() + fence_down_after_the_worker: list[bool] = [] + real_apply = provider._apply_effort_overlay + + def blocked_apply(**kwargs): + worker_started.set() + try: + assert release_worker.wait(timeout=5) + return real_apply(**kwargs) + finally: + worker_finished.set() + + @contextmanager + def fence(): + try: + yield + finally: + fence_down_after_the_worker.append(worker_finished.is_set()) + + monkeypatch.setattr(provider, "_apply_effort_overlay", blocked_apply) + provider.arm_explicit_effort_default(fence) + start_task = asyncio.create_task(provider.start()) + try: + await async_wait_until( + worker_started.is_set, describe=lambda: "worker_started was never set" + ) + start_task.cancel() + await asyncio.sleep(0) + assert not start_task.done() + assert fence_down_after_the_worker == [] + finally: + release_worker.set() + + with pytest.raises(asyncio.CancelledError): + await _await_test(start_task, "start_task") + assert fence_down_after_the_worker == [True] + assert _read_cli_overlay(tmp_path) == {} + assert provider._explicit_effort_default_projected is True + assert provider._explicit_effort_default_pending is True + assert provider.explicit_effort_default_applied is False + provider._start_kiro_runtime.assert_not_awaited() + + def test_no_level_removes_both_family_entries_only_for_explicit_default(self, tmp_path): + cli_json = self._write( + tmp_path, + { + "chat.modelDefaults": { + self._MODEL: { + "output_config": {"effort": "max"}, + "reasoning": {"effort": "high"}, + } + }, + "other": 1, + }, + ) + provider = self._provider(tmp_path) + + assert provider._apply_effort_overlay() is True + guarded = json.loads(cli_json.read_text(encoding="utf-8")) + assert guarded["chat.modelDefaults"][self._MODEL]["output_config"] == {"effort": "max"} + assert guarded["chat.modelDefaults"][self._MODEL]["reasoning"] == {"effort": "high"} + + assert provider._apply_effort_overlay(replace_unowned=True) is True + data = json.loads(cli_json.read_text(encoding="utf-8")) + entry = data.get("chat.modelDefaults", {}).get(self._MODEL, {}) + assert "effort" not in entry.get("output_config", {}) + assert "effort" not in entry.get("reasoning", {}) + assert data["other"] == 1 + + def test_a_resolved_level_replaces_an_operator_entry(self, tmp_path): + cli_json = self._operator_entry(tmp_path, "max") + provider = self._provider(tmp_path, effort_per_model={self._MODEL: "high"}) + + assert provider._apply_effort_overlay(replace_unowned=True) is True + + assert _read_cli_overlay(tmp_path) == {self._MODEL: "high"} + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data[_KIROCREW_EFFORT_OWNED_KEY] == {self._MODEL: "high"} + + def test_the_auto_model_keeps_the_ownership_guard(self, tmp_path): + # Under auto no concrete model is known before kiro-cli reads the file, + # so, as for a live auto session, only entries Kiro Crew recorded go. + owned_model = "claude-sonnet-4.6" + stamp = 1_700_000_000 + self._write( + tmp_path, + { + "chat.modelDefaults": { + owned_model: {"output_config": {"effort": "low"}}, + self._MODEL: {"output_config": {"effort": "max"}}, + }, + _KIROCREW_EFFORT_OWNED_KEY: {owned_model: "low"}, + "kirocrew.effortOwnedStamp": stamp, + }, + ) + os.utime(tmp_path / ".kiro" / "settings" / "cli.json", (stamp, stamp)) + provider = self._provider(tmp_path, model="auto") + + assert provider._apply_effort_overlay(replace_unowned=True) is True + assert _read_cli_overlay(tmp_path) == {self._MODEL: "max"} + + @pytest.mark.parametrize( + ("model", "backend"), + [("claude-haiku-4.5", ACP_BACKEND_KIRO), ("claude-opus-4.7", "claude")], + ids=["model-without-effort", "backend-that-reads-no-overlay"], + ) + def test_nothing_to_remove_counts_as_applied(self, tmp_path, model, backend): + cli_json = self._operator_entry(tmp_path, "max", model=model) + before = cli_json.read_bytes() + provider = self._provider(tmp_path, model=model, acp_backend=backend) + + assert provider._apply_effort_overlay(replace_unowned=True) is True + assert cli_json.read_bytes() == before diff --git a/test/test_effort_spawn_fence_order.py b/test/test_effort_spawn_fence_order.py new file mode 100644 index 00000000000..bc9ec1246d3 --- /dev/null +++ b/test/test_effort_spawn_fence_order.py @@ -0,0 +1,515 @@ +"""How a Default start, a level write and the cold-start queue order themselves. + +A Default start takes its work directory's overlay fence only once it holds a +cold-start permit, and waits for that directory's writes holding neither, so a +pick is never held off by a start that is only queued and a start waiting on a +fence never holds a gateway-wide admission slot. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from effort_helpers import _observe_directory_wait +from test_effort import _await_test + +from kiro_crew.acp.runtime import _ColdStartAdmission +from kiro_crew.acp.types import ACP_BACKEND_KIRO +from kiro_crew.providers import acp as acp_provider +from kiro_crew.providers.acp import _write_cli_overlay +from kiro_crew.start_priority import START_QUEUE_ADMISSION, StartPriority +from kiro_crew.testing.wait import async_wait_until, await_until + +pytestmark = pytest.mark.usefixtures("no_zero_tool_check") + + +class TestFenceArrivalOrder: + """Windows and writes on one fence are served in arrival order.""" + + _UNBLOCKED_DEADLINE_SECS = 10.0 + + @pytest.mark.asyncio + async def test_a_window_queued_after_a_write_waits_for_it(self, tmp_path): + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + assert await fence.acquire("window", 0.1) is True + write_task = asyncio.create_task(fence.acquire("write", self._UNBLOCKED_DEADLINE_SECS)) + await asyncio.sleep(0) + late_window = asyncio.create_task(fence.acquire("window", self._UNBLOCKED_DEADLINE_SECS)) + await asyncio.sleep(0) + assert not write_task.done() + assert not late_window.done() + + fence.release("window") + assert await _await_test(write_task, "write_task") is True + assert not late_window.done(), "the late window overtook the queued write" + + fence.release("write") + assert await _await_test(late_window, "late_window") is True + fence.release("window") + + @pytest.mark.asyncio + async def test_window_and_write_queued_behind_a_held_write_resolve_in_order(self, tmp_path): + # Held write, then a window, then a write: the second write waits for the + # window, so the window must not wait for it. + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + order: list[str] = [] + + async def take(mode: str) -> None: + assert await fence.acquire(mode, self._UNBLOCKED_DEADLINE_SECS) is True + order.append(mode) + await asyncio.sleep(0) + fence.release(mode) + + assert await fence.acquire("write", 0.1) is True + window_task = asyncio.create_task(take("window")) + await asyncio.sleep(0) + write_task = asyncio.create_task(take("write")) + await asyncio.sleep(0) + fence.release("write") + + await asyncio.wait_for( + asyncio.gather(window_task, write_task), self._UNBLOCKED_DEADLINE_SECS + ) + assert order == ["window", "write"] + + @pytest.mark.asyncio + async def test_a_waiter_that_times_out_stops_blocking_a_later_one(self, tmp_path): + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + assert await fence.acquire("window", 0.1) is True + write_task = asyncio.create_task(fence.acquire("write", 0.01)) + await asyncio.sleep(0) + assert not write_task.done() + + assert await _await_test(write_task, "write_task") is False + + assert await fence.acquire("window", 0.01) is True + fence.release("window") + fence.release("window") + + +class TestDefaultStartAdmittedWindow: + """A Default start holds the work-directory fence and its permit in one order only. + + It waits for the directory holding nothing, queues for its cold-start permit + holding nothing on the fence, and takes the fence only with the permit in + hand. Each test here pins one half of that: no permit held while it waits, + and no fence registration held while it queues. + """ + + _MODEL = "claude-opus-4.7" + _DEADLINE_SECS = 10.0 + + @staticmethod + def _provider(work_dir): + from kiro_crew.acp.types import ACP_BACKEND_KIRO + from kiro_crew.providers import acp as acp_provider + + return acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=work_dir, + model=TestDefaultStartAdmittedWindow._MODEL, + ) + + @staticmethod + def _fence(work_dir): + from kiro_crew.providers import acp as acp_provider + + return acp_provider._workspace_effort_overlay_fence( + os.path.realpath(Path(work_dir) / ".kiro" / "settings") + ) + + @staticmethod + def _width_one_admission(monkeypatch): + from kiro_crew.acp import runtime_start as acp_runtime_start_module + + admission = _ColdStartAdmission(limit=1) + monkeypatch.setattr(acp_runtime_start_module, "_cold_start_admission", lambda: admission) + return admission + + @pytest.mark.asyncio + async def test_a_start_waiting_for_a_write_holds_no_admission_permit( + self, tmp_path, monkeypatch + ): + # A start waiting out a fence here holds no cold-start permit, so cold + # starts in projects that share nothing with this directory never queue + # behind it. + from kiro_crew.providers.acp import _write_cli_overlay + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = self._width_one_admission(monkeypatch) + waiting = _observe_directory_wait(monkeypatch) + fence = self._fence(tmp_path) + assert await fence.acquire("write", 0.1) is True + + provider = self._provider(tmp_path) + entered_start = asyncio.Event() + + async def runtime_start_stub(): + entered_start.set() + + monkeypatch.setattr(provider, "_start_kiro_runtime", runtime_start_stub) + start_task = asyncio.create_task(provider.start()) + try: + await await_until(waiting, what="the start waiting for the directory") + assert admission.active == 0 + assert not entered_start.is_set() + + # The only permit is free while the start waits, so an unrelated cold + # start is admitted at once. + await await_until( + asyncio.ensure_future(admission.acquire(StartPriority.FOREGROUND)), + what="an unrelated cold start admitted while the start waits", + ) + admission.release(StartPriority.FOREGROUND) + + fence.release("write") + await asyncio.wait_for(entered_start.wait(), self._DEADLINE_SECS) + finally: + await asyncio.wait_for(start_task, self._DEADLINE_SECS) + assert admission.active == 0 + + @pytest.mark.asyncio + async def test_a_write_arriving_while_the_start_queues_gets_the_permit_back( + self, tmp_path, monkeypatch + ): + from kiro_crew.providers.acp import _write_cli_overlay + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = self._width_one_admission(monkeypatch) + fence = self._fence(tmp_path) + await admission.acquire(StartPriority.FOREGROUND) + + provider = self._provider(tmp_path) + entered_start = asyncio.Event() + + async def runtime_start_stub(): + entered_start.set() + + monkeypatch.setattr(provider, "_start_kiro_runtime", runtime_start_stub) + start_task = asyncio.create_task(provider.start()) + try: + # Interval polling, not loop turns: the start resolves its fence key + # in a worker thread before it reaches the queue. + await async_wait_until(lambda: admission.queued == 1, timeout=self._DEADLINE_SECS) + # The write arrives while the start is only queued, so nothing holds + # it off. + assert await fence.acquire("write", 0.1) is True + + admission.release(StartPriority.FOREGROUND) + # The start is granted the permit, finds the write, and hands the + # permit straight back rather than waiting on the fence with it. + await async_wait_until( + lambda: admission.active == 0 and admission.queued == 0, + timeout=self._DEADLINE_SECS, + ) + assert not entered_start.is_set() + + fence.release("write") + await asyncio.wait_for(entered_start.wait(), self._DEADLINE_SECS) + finally: + await asyncio.wait_for(start_task, self._DEADLINE_SECS) + assert admission.active == 0 + + @pytest.mark.asyncio + async def test_a_task_started_inside_the_window_spawns_on_its_own_permit( + self, tmp_path, monkeypatch + ): + # A task created under the window inherits the ContextVar's context, so + # without the owner-task check it would spawn on this start's permit. + from types import SimpleNamespace + + from kiro_crew.acp import runtime_spawn_entry + from kiro_crew.acp import runtime_start as acp_runtime_start_module + from kiro_crew.acp.types import ACP_BACKEND_KIRO + from kiro_crew.providers.acp import _write_cli_overlay + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = _ColdStartAdmission(limit=2) + monkeypatch.setattr(acp_runtime_start_module, "_cold_start_admission", lambda: admission) + active_inside_child: list[int] = [] + + async def child_spawn_admitted(runtime): + active_inside_child.append(admission.active) + + runtime = SimpleNamespace( + _process=None, + _process_tree_confirmed_dead=False, + _acp_backend=ACP_BACKEND_KIRO, + ) + runtime._spawn_admitted_rederiving_once = lambda: child_spawn_admitted(runtime) + + active_after_child: list[int] = [] + + async def runtime_start_stub(): + child = asyncio.create_task( + runtime_spawn_entry.spawn(runtime, start_priority=StartPriority.BACKGROUND) + ) + await asyncio.wait_for(child, self._DEADLINE_SECS) + active_after_child.append(admission.active) + + provider = self._provider(tmp_path) + monkeypatch.setattr(provider, "_start_kiro_runtime", runtime_start_stub) + + await asyncio.wait_for(provider.start(), self._DEADLINE_SECS) + + # Two permits held at once: the start's own and the one the child task + # queued for. One permit would mean the child ran on the start's. + assert active_inside_child == [2] + # The child released only its own, so the start still holds the permit it + # hands to its first real spawn. + assert active_after_child == [1] + assert admission.active == 0 + + @pytest.mark.asyncio + async def test_the_fence_ceiling_does_not_count_the_admission_queue_wait( + self, tmp_path, monkeypatch + ): + from kiro_crew.providers import acp as acp_provider + from kiro_crew.providers.acp import _write_cli_overlay + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = self._width_one_admission(monkeypatch) + ceiling_secs = 0.05 + monkeypatch.setattr( + acp_provider, "_WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS", ceiling_secs + ) + await admission.acquire(StartPriority.FOREGROUND) + + provider = self._provider(tmp_path) + entered_start = asyncio.Event() + + async def runtime_start_stub(): + entered_start.set() + + monkeypatch.setattr(provider, "_start_kiro_runtime", runtime_start_stub) + start_task = asyncio.create_task(provider.start()) + try: + await async_wait_until(lambda: admission.queued == 1, timeout=self._DEADLINE_SECS) + # The queue wait has to outlast the fence ceiling for this to prove + # anything, so the clock is the condition here, not a synchronization + # device: no write exists in this directory, so the only thing the + # start can be waiting for is its permit. + loop = asyncio.get_running_loop() + queued_at = loop.time() + await async_wait_until( + lambda: loop.time() - queued_at > ceiling_secs * 4, + timeout=self._DEADLINE_SECS, + ) + assert not entered_start.is_set() + + admission.release(StartPriority.FOREGROUND) + await asyncio.wait_for(entered_start.wait(), self._DEADLINE_SECS) + finally: + await asyncio.wait_for(start_task, self._DEADLINE_SECS) + assert admission.active == 0 + + @pytest.mark.asyncio + async def test_a_start_queued_on_cold_start_admission_holds_no_window( + self, tmp_path, monkeypatch + ): + # A start that is only queued for a cold-start permit holds no + # work-directory window, so a level pick in that directory is not held off + # by a start that has not begun. + from kiro_crew.acp import runtime_start as acp_runtime_start_module + from kiro_crew.start_priority import StartPriority + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = acp_runtime_start_module._cold_start_admission() + for _ in range(admission.semaphore.limit): + await admission.acquire(StartPriority.FOREGROUND) + + provider = self._provider(tmp_path) + entered_start = asyncio.Event() + + async def runtime_start_stub(): + entered_start.set() + + monkeypatch.setattr(provider, "_start_kiro_runtime", runtime_start_stub) + start_task = asyncio.create_task(provider.start()) + try: + await async_wait_until(lambda: admission.queued == 1, timeout=self._DEADLINE_SECS) + assert not entered_start.is_set() + + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + # The start is queued for admission, so it holds no window and a + # gateway write gets straight through. + assert await fence.acquire("write", 0.1) is True + fence.release("write") + + admission.release(StartPriority.FOREGROUND) + await asyncio.wait_for(entered_start.wait(), self._DEADLINE_SECS) + finally: + await _await_test(start_task, "start_task") + for _ in range(admission.semaphore.limit - 1): + admission.release(StartPriority.FOREGROUND) + + @pytest.mark.asyncio + async def test_a_start_whose_stub_never_spawns_returns_its_admission_permit( + self, tmp_path, monkeypatch + ): + from kiro_crew.acp import runtime_start as acp_runtime_start_module + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = acp_runtime_start_module._cold_start_admission() + provider = self._provider(tmp_path) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + + await asyncio.wait_for(provider.start(), self._DEADLINE_SECS) + + assert admission.active == 0 + + @pytest.mark.asyncio + async def test_a_window_wait_that_times_out_returns_its_admission_permit( + self, tmp_path, monkeypatch + ): + from kiro_crew.acp import runtime_start as acp_runtime_start_module + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = acp_runtime_start_module._cold_start_admission() + provider = self._provider(tmp_path) + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + fence = acp_provider._workspace_effort_overlay_fence( + os.path.realpath(tmp_path / ".kiro" / "settings") + ) + assert await fence.acquire("write", 0.1) is True + monkeypatch.setattr( + acp_provider, + "_WORKSPACE_EFFORT_OVERLAY_FENCE_WINDOW_TIMEOUT_SECS", + 0.01, + ) + + with pytest.raises(RuntimeError, match="fence wait timed out"): + await asyncio.wait_for(provider.start(), self._DEADLINE_SECS) + + assert admission.active == 0 + fence.release("write") + + @pytest.mark.asyncio + @pytest.mark.parametrize("spawn_runtime", [True, False], ids=["spawn", "no-spawn"]) + async def test_handed_down_grant_precedes_start_work_and_is_reported_once( + self, tmp_path, monkeypatch, spawn_runtime + ): + from kiro_crew.acp import runtime_spawn_entry + + _write_cli_overlay(tmp_path, self._MODEL, "max") + admission = self._width_one_admission(monkeypatch) + events: list[object] = [] + + def queued(queue): + events.append(("queued", queue)) + + def acquired(wait_ms, queue): + events.append(("acquired", wait_ms, queue)) + + provider = acp_provider.AcpProvider( + acp_backend=ACP_BACKEND_KIRO, + work_dir=tmp_path, + model=self._MODEL, + on_gate_queued=queued, + on_gate_acquired=acquired, + ) + monkeypatch.setattr( + provider, "_apply_effort_overlay", lambda: events.append("effort-overlay") + ) + monkeypatch.setattr( + provider, "_apply_tool_search_overlay", lambda: events.append("tool-search") + ) + + runtime = SimpleNamespace( + _process=None, + _process_tree_confirmed_dead=False, + _acp_backend=ACP_BACKEND_KIRO, + ) + + async def spawned(): + events.append("spawned") + + runtime._spawn_admitted_rederiving_once = spawned + + async def runtime_start_stub(): + events.append("start-runtime") + if spawn_runtime: + await runtime_spawn_entry.spawn( + runtime, + start_priority=StartPriority.BACKGROUND, + on_gate_queued=queued, + on_gate_acquired=acquired, + ) + + monkeypatch.setattr(provider, "_start_kiro_runtime", runtime_start_stub) + + await asyncio.wait_for(provider.start(), self._DEADLINE_SECS) + + gate_events = [event for event in events if isinstance(event, tuple)] + assert len(gate_events) == 2 + assert gate_events[0] == ("queued", START_QUEUE_ADMISSION) + assert gate_events[1][0] == "acquired" + assert gate_events[1][2] == START_QUEUE_ADMISSION + assert events.index(gate_events[1]) < events.index("effort-overlay") + assert events.index(gate_events[1]) < events.index("tool-search") + assert events.index(gate_events[1]) < events.index("start-runtime") + assert events.count("spawned") == int(spawn_runtime) + assert admission.active == 0 + + @pytest.mark.asyncio + async def test_the_first_spawn_of_a_handed_down_start_does_not_queue_again(self): + # With every other permit held, a spawn that acquired its own would wait + # for one that this start is already holding, so this bounded wait is the + # proof: it completes only because the permit was handed down. + from kiro_crew.acp import runtime_spawn_entry + from kiro_crew.acp import runtime_start as acp_runtime_start_module + from kiro_crew.start_priority import StartPriority + + admission = acp_runtime_start_module._cold_start_admission() + for _ in range(admission.semaphore.limit - 1): + await admission.acquire(StartPriority.FOREGROUND) + + spawned = asyncio.Event() + + async def spawn_admitted(runtime): + spawned.set() + + runtime = SimpleNamespace( + _process=None, + _process_tree_confirmed_dead=False, + _acp_backend=ACP_BACKEND_KIRO, + ) + runtime._spawn_admitted_rederiving_once = lambda: spawn_admitted(runtime) + + acquired_wait_ms: list[float] = [] + async with runtime_spawn_entry.handed_down_cold_start_admission( + StartPriority.BACKGROUND, + on_gate_acquired=lambda wait_ms, queue: acquired_wait_ms.append(wait_ms), + ) as permit: + assert admission.active == admission.semaphore.limit + await asyncio.wait_for( + runtime_spawn_entry.spawn( + runtime, + start_priority=StartPriority.BACKGROUND, + on_gate_acquired=lambda wait_ms, queue: acquired_wait_ms.append(wait_ms), + ), + self._DEADLINE_SECS, + ) + assert spawned.is_set() + # The lifecycle reported the grant once, and the spawn consumed + # and released the permit without reporting it again. + assert acquired_wait_ms == [permit.wait_ms] + assert admission.active == admission.semaphore.limit - 1 + + assert admission.active == admission.semaphore.limit - 1 + for _ in range(admission.semaphore.limit - 1): + admission.release(StartPriority.FOREGROUND) + assert admission.active == 0 diff --git a/test/test_essential_delivery.py b/test/test_essential_delivery.py index 5e689043c8e..9df17e5c5df 100644 --- a/test/test_essential_delivery.py +++ b/test/test_essential_delivery.py @@ -48,6 +48,7 @@ def isolated_home(tmp_path, monkeypatch): class Wire: def __init__(self, project, backend=ACP_BACKEND_CLAUDE): self._work_dir = project + self._model = "" self.backend = backend self._session_id = "native-conversation" # Read by the startup effort push on a config-option harness (KAS); no diff --git a/test/test_harness_parity.py b/test/test_harness_parity.py index 22a16954600..cae1d2f0732 100644 --- a/test/test_harness_parity.py +++ b/test/test_harness_parity.py @@ -824,6 +824,14 @@ def test_only_overlay_readers_are_written_to() -> None: assert ACP_BACKEND_KAS not in readers +def test_non_kiro_spawn_does_not_take_the_effort_overlay_fence(tmp_path) -> None: + """H6: a backend that does not read cli.json never joins its fence.""" + provider = providers_acp.AcpProvider( + acp_backend=ACP_BACKEND_CODEX, work_dir=tmp_path, model="claude-opus-4.7" + ) + assert provider._effort_overlay_fence_mode() is None + + def test_codex_resolves_its_own_adapter_and_declares_its_own_handshake() -> None: """H9/H10, on the core that drives codex. diff --git a/test/test_kiro_cli_effort_ownership_pin.py b/test/test_kiro_cli_effort_ownership_pin.py new file mode 100644 index 00000000000..5ecca0e81d1 --- /dev/null +++ b/test/test_kiro_cli_effort_ownership_pin.py @@ -0,0 +1,96 @@ +"""Tripwire for the ``kirocrew.effortOwned`` load-tolerance and stamp verification. + +The effort ownership record lives in the workspace ``cli.json``, a file kiro-cli +itself loads and rewrites. The record counts only while its sibling +``kirocrew.effortOwnedStamp`` equals the file's whole-second mtime, so the design +needs two things of kiro-cli: it loads a workspace ``cli.json`` carrying both keys +it does not know, and its own ``settings --workspace`` write leaves the mtime +unequal to the recorded stamp, which voids the record. Whether that write keeps or +drops the keys decides nothing. Both checks were last run by hand on kiro-cli +2.27.1 and nothing else repeats them. This test ties the re-check to the one +kiro-cli version the repo does control: the BUNDLED CLI pinned in +``packaging/kiro-cli-version``, read by the desktop and Windows build +workflows. Raising that pin past the verified version fails here until someone +re-runs the checks. The same re-check confirms that a live ``/effort`` push still +sets a session's effort: that push carries a chat's level over a shared overlay +holding another one. + +It covers the bundled CLI only. A ``kiro-cli`` the host installed itself, which +``kiro_cli.resolve_kiro_cli`` can still pick, is not gated by anything in this +repo. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from kiro_crew.mcp_hot_reload import parse_kiro_cli_version + +# The newest kiro-cli on which the checks in ``docs/reference/kiro-cli/README.md`` +# were observed to hold: it loads a workspace ``cli.json`` carrying the unknown +# ``kirocrew.effortOwned`` and ``kirocrew.effortOwnedStamp`` keys, a +# ``kiro-cli settings --workspace`` write leaves the file's whole-second mtime +# unequal to the recorded stamp, and a live ``/effort`` push sets the session's +# effort. Raise it only after re-running those checks on the new version. Kept in +# the test, not in ``src/``: production code has no reader. +EFFORT_OWNERSHIP_VERIFIED_KIRO_CLI: tuple[int, int, int] = (2, 27, 1) + +# Resolved from this file, not the CWD, so the test reads the same pin from any +# invocation directory. +BUNDLED_KIRO_CLI_PIN_FILE = Path(__file__).resolve().parents[1] / "packaging" / "kiro-cli-version" + + +def _format_version(version: tuple[int, int, int]) -> str: + return ".".join(str(component) for component in version) + + +def read_bundled_kiro_cli_pin(pin_file: Path) -> tuple[int, int, int]: + """The bundled kiro-cli version, parsed from its leading numeric components. + + A pre-release or build suffix (``2.25.0-rc1``) parses as ``(2, 25, 0)``; a + missing patch component reads as 0. A pin that holds no version-shaped token + is a failure of the pin file, so it fails loudly rather than reading as 0.0.0. + """ + text = pin_file.read_text(encoding="utf-8") + version = parse_kiro_cli_version(text) + if version is None: + pytest.fail(f"{pin_file} holds no version-shaped token: {text.strip()!r}") + return version + + +def test_bundled_pin_not_newer_than_verified_ownership_version(): + """The bundled kiro-cli pin must not outrun the load-tolerance and stamp checks. + + Covers ``packaging/kiro-cli-version`` (the BUNDLED CLI) only, NOT a + ``kiro-cli`` the host installed itself. When the pin is + raised past ``EFFORT_OWNERSHIP_VERIFIED_KIRO_CLI``: re-run the checks in + ``docs/reference/kiro-cli/README.md`` (on the new version kiro-cli loads a + workspace ``cli.json`` carrying the ``kirocrew.effortOwned`` and + ``kirocrew.effortOwnedStamp`` keys, with ``settings list`` showing the + workspace ``chat.modelDefaults`` and a chat spawning and answering; a + ``kiro-cli settings --workspace`` write leaves the file's whole-second mtime + unequal to the recorded stamp; and a live ``/effort`` push sets the session's + effort), then raise this constant and the verified-version note in + ``docs/system-specs/modules/providers.md``. + """ + pinned = read_bundled_kiro_cli_pin(BUNDLED_KIRO_CLI_PIN_FILE) + assert pinned <= EFFORT_OWNERSHIP_VERIFIED_KIRO_CLI, ( + f"packaging/kiro-cli-version pins the bundled kiro-cli at " + f"{_format_version(pinned)}, newer than " + f"{_format_version(EFFORT_OWNERSHIP_VERIFIED_KIRO_CLI)}, the last version on " + f"which kiro-cli was verified to load a workspace cli.json carrying the unknown " + f"kirocrew.effortOwned and kirocrew.effortOwnedStamp keys and to leave the " + f"file's mtime unequal to the stamp after its own write. This gate covers the " + f"bundled CLI only, NOT a kiro-cli the host installed itself. " + f"Re-run the checks in docs/reference/kiro-cli/README.md on " + f"{_format_version(pinned)}: kiro-cli must still load a workspace cli.json " + f"carrying both keys (`settings list` shows the workspace chat.modelDefaults, " + f"and a chat spawns and answers), a `kiro-cli settings --workspace` write must " + f"leave the file's whole-second mtime unequal to the recorded stamp, and a live " + f"`/effort` push must set the session's effort. Then raise " + f"EFFORT_OWNERSHIP_VERIFIED_KIRO_CLI in " + f"test/test_kiro_cli_effort_ownership_pin.py and the verified-version note in " + f"docs/system-specs/modules/providers.md." + ) diff --git a/test/test_native_skill_projection.py b/test/test_native_skill_projection.py index 156c0a8d8dd..b2463cf588c 100644 --- a/test/test_native_skill_projection.py +++ b/test/test_native_skill_projection.py @@ -14,6 +14,7 @@ import json import logging import os +import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -21,14 +22,21 @@ import native_skill_projection_helpers import pytest +from kiro_crew import platform_compat from kiro_crew.acp import skill_projection as projection from kiro_crew.agent_spec_format import iter_agent_spec_files +from kiro_crew.providers import acp as acp_provider +from kiro_crew.workspace_cli_settings import ( + CLI_SETTINGS_LOCK_NAME, + workspace_cli_settings_fence_key, +) # The shared fixtures and helpers, bound here by name: pytest registers the fixtures # from this namespace. _legacy_alias = native_skill_projection_helpers._legacy_alias _no_eager_prune = native_skill_projection_helpers._no_eager_prune _prune_on_every_spawn = native_skill_projection_helpers._prune_on_every_spawn +cyclic_gc_quiesced = native_skill_projection_helpers.cyclic_gc_quiesced native_tree = native_skill_projection_helpers.native_tree @@ -542,6 +550,49 @@ def test_rollback_restores_original_local_value_and_presence(native_tree, monkey assert settings.read_bytes() == before +def test_projection_keeps_non_ascii_settings_unescaped(native_tree): + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + settings = project / ".kiro/settings/cli.json" + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({"operator.note": "界"}, ensure_ascii=False), encoding="utf-8") + projection.prepare_native_skill_projection(project) + raw = settings.read_bytes() + assert json.loads(raw)["chat.disableInheritingDefaultResources"] is True + assert "界".encode("utf-8") in raw + assert b"\\u754c" not in raw + + +def test_rollback_keeps_non_ascii_settings_unescaped(native_tree, monkeypatch): + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + settings = project / ".kiro/settings/cli.json" + settings.parent.mkdir(parents=True) + settings.write_text(json.dumps({"operator.note": "界"}, ensure_ascii=False), encoding="utf-8") + projection.prepare_native_skill_projection(project) + monkeypatch.setenv("KIROCREW_NATIVE_SKILL_PROJECTION", "0") + assert projection.prepare_native_skill_projection(project) is None + raw = settings.read_bytes() + assert json.loads(raw) == {"operator.note": "界"} + assert "界".encode("utf-8") in raw + assert b"\\u754c" not in raw + + +def test_projection_and_rollback_keep_an_unpaired_surrogate_escaped(native_tree, monkeypatch): + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + settings = project / ".kiro/settings/cli.json" + settings.parent.mkdir(parents=True) + settings.write_text('{"operator.note": "\\ud800"}', encoding="utf-8") + projection.prepare_native_skill_projection(project) + projected = json.loads(settings.read_bytes()) + assert projected["operator.note"] == "\ud800" + assert projected["chat.disableInheritingDefaultResources"] is True + monkeypatch.setenv("KIROCREW_NATIVE_SKILL_PROJECTION", "0") + assert projection.prepare_native_skill_projection(project) is None + assert json.loads(settings.read_bytes()) == {"operator.note": "\ud800"} + + @pytest.mark.parametrize("operator_value", [False, None, "deleted"]) def test_rollback_preserves_operator_changes(native_tree, monkeypatch, operator_value): _home, agents, project = native_tree @@ -820,3 +871,337 @@ def test_global_preference_refresh_uses_only_literal_true(native_tree, value): resources = refreshed.specs["custom"]["resources"] assert any("AGENTS.md" in item for item in resources) is (value is not True) assert any("steering" in item for item in resources) is (value is not True) + + +class TestTheProjectionWriteCarriesEffortOwnership: + """The settings republish keeps a valid effort record valid and a void one void. + + ``prepare_native_skill_projection`` rewrites the whole workspace ``cli.json`` + on every spawn and warm session start. Kiro Crew's effort record in that file + counts only while its stamp equals the file's mtime, so an unstamped republish + voided Kiro Crew's own record and the owned clear then left the level behind as + operator-written. An operator save that keeps the keys must stay void across + the same write: the projection may not re-validate what it cannot prove it wrote. + """ + + MODEL = "claude-opus-4.7" + + @staticmethod + def _level(project, model): + cli_json = project / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + model_cfg = data.get("chat.modelDefaults", {}).get(model, {}) + return model_cfg.get("output_config", {}).get("effort") + + @staticmethod + def _operator_rewrite_keeping_keys(project): + cli_json = project / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + cli_json.write_text(json.dumps(data), encoding="utf-8") + assert data["kirocrew.effortOwnedStamp"] != int(cli_json.stat().st_mtime) + + def test_the_projection_write_keeps_kiro_crews_record_valid(self, native_tree): + from kiro_crew.providers.acp import _clear_cli_overlay_effort, _write_cli_overlay + + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + _write_cli_overlay(project, self.MODEL, "max") + + assert projection.prepare_native_skill_projection(project) is not None + + cli_json = project / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data[projection._INHERIT_SETTING] is True + assert data["kirocrew.effortOwned"] == {self.MODEL: "max"} + assert data["kirocrew.effortOwnedStamp"] == int(cli_json.stat().st_mtime) + assert _clear_cli_overlay_effort(project, None, owned_only=True) is True + assert self._level(project, self.MODEL) is None + + def test_a_repeat_projection_write_leaves_the_owned_file_untouched( + self, native_tree, monkeypatch + ): + from types import SimpleNamespace + + from kiro_crew import workspace_cli_settings + from kiro_crew.providers.acp import _clear_cli_overlay_effort, _write_cli_overlay + + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + clock = [1_790_000_000.0] + monkeypatch.setattr(workspace_cli_settings, "time", SimpleNamespace(time=lambda: clock[0])) + _write_cli_overlay(project, self.MODEL, "max") + assert projection.prepare_native_skill_projection(project) is not None + cli_json = project / ".kiro" / "settings" / "cli.json" + before = cli_json.read_bytes() + before_stat = cli_json.stat() + clock[0] += 10 + + assert projection.prepare_native_skill_projection(project) is not None + + after_stat = cli_json.stat() + assert cli_json.read_bytes() == before + assert (after_stat.st_ino, after_stat.st_mtime_ns) == ( + before_stat.st_ino, + before_stat.st_mtime_ns, + ) + data = json.loads(before) + assert data[projection._INHERIT_SETTING] is True + assert data["kirocrew.effortOwned"] == {self.MODEL: "max"} + assert data["kirocrew.effortOwnedStamp"] == int(after_stat.st_mtime) + assert _clear_cli_overlay_effort(project, None, owned_only=True) is True + assert self._level(project, self.MODEL) is None + + def test_the_projection_write_does_not_revalidate_a_void_record(self, native_tree): + from kiro_crew.providers.acp import _clear_cli_overlay_effort, _write_cli_overlay + + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + _write_cli_overlay(project, self.MODEL, "max") + self._operator_rewrite_keeping_keys(project) + + assert projection.prepare_native_skill_projection(project) is not None + + cli_json = project / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert data[projection._INHERIT_SETTING] is True + assert "kirocrew.effortOwned" not in data + assert "kirocrew.effortOwnedStamp" not in data + assert _clear_cli_overlay_effort(project, None, owned_only=True) is True + assert self._level(project, self.MODEL) == "max" + + def test_the_rollback_write_keeps_kiro_crews_record_valid(self, native_tree): + from kiro_crew.providers.acp import _clear_cli_overlay_effort, _write_cli_overlay + + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + assert projection.prepare_native_skill_projection(project) is not None + _write_cli_overlay(project, self.MODEL, "max") + + assert projection.prepare_native_skill_projection(project, enabled=False) is None + + cli_json = project / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert projection._MANAGED_SETTING not in data + assert data["kirocrew.effortOwned"] == {self.MODEL: "max"} + assert data["kirocrew.effortOwnedStamp"] == int(cli_json.stat().st_mtime) + assert _clear_cli_overlay_effort(project, None, owned_only=True) is True + assert self._level(project, self.MODEL) is None + + def test_the_rollback_write_does_not_revalidate_a_void_record(self, native_tree): + from kiro_crew.providers.acp import _clear_cli_overlay_effort, _write_cli_overlay + + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + assert projection.prepare_native_skill_projection(project) is not None + _write_cli_overlay(project, self.MODEL, "max") + self._operator_rewrite_keeping_keys(project) + + assert projection.prepare_native_skill_projection(project, enabled=False) is None + + cli_json = project / ".kiro" / "settings" / "cli.json" + data = json.loads(cli_json.read_text(encoding="utf-8")) + assert projection._MANAGED_SETTING not in data + assert "kirocrew.effortOwned" not in data + assert "kirocrew.effortOwnedStamp" not in data + assert _clear_cli_overlay_effort(project, None, owned_only=True) is True + assert self._level(project, self.MODEL) == "max" + + +@pytest.mark.parametrize("redirect", [".kiro", "settings"]) +def test_settings_link_outside_is_never_probed_by_any_prelock_path( + native_tree, tmp_path, monkeypatch, redirect +): + _home, agents, _project = native_tree + work_dir = tmp_path / "linked-work" + work_dir.mkdir() + outside = tmp_path / "outside" + outside_settings = outside / "settings" + outside_settings.mkdir(parents=True) + if redirect == ".kiro": + target = outside + link = work_dir / ".kiro" + else: + (work_dir / ".kiro").mkdir() + target = outside_settings + link = work_dir / ".kiro" / "settings" + platform_compat.symlink_or_junction(target, link) + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + + outside_root = os.path.normcase(os.path.abspath(outside)) + calls = [] + + def record(name, original): + def wrapped(path, *args, **kwargs): + try: + raw = os.fspath(path) + text = os.fsdecode(raw) + except TypeError: + text = repr(path) + calls.append((name, text)) + return original(path, *args, **kwargs) + + return wrapped + + with monkeypatch.context() as probes: + probes.setattr(os, "lstat", record("lstat", os.lstat)) + probes.setattr(os, "stat", record("stat", os.stat)) + probes.setattr(os, "open", record("open", os.open)) + probes.setattr(os, "readlink", record("readlink", os.readlink)) + probes.setattr(os, "scandir", record("scandir", os.scandir)) + probes.setattr(os, "listdir", record("listdir", os.listdir)) + probes.setattr(os.path, "realpath", record("realpath", os.path.realpath)) + + with pytest.raises(OSError, match="resolves outside"): + acp_provider._write_cli_overlay(work_dir, "claude-opus-4.7", "max") + with pytest.raises(OSError, match="resolves outside"): + acp_provider._write_tool_search_overlay(work_dir, True) + assert acp_provider._clear_cli_overlay_effort(work_dir, "claude-opus-4.7") is False + auto_provider = acp_provider.AcpProvider(work_dir=work_dir, model="auto") + default_provider = acp_provider.AcpProvider(work_dir=work_dir, model="claude-opus-4.7") + assert auto_provider._apply_effort_overlay() is False + assert default_provider._apply_effort_overlay() is False + assert workspace_cli_settings_fence_key(work_dir) == os.path.join( + os.path.realpath(work_dir), ".kiro", "settings" + ) + probes.setenv("KIROCREW_NATIVE_SKILL_PROJECTION", "0") + assert projection.prepare_native_skill_projection(work_dir) is None + probes.setenv("KIROCREW_NATIVE_SKILL_PROJECTION", "1") + projection.prepare_native_skill_projection(work_dir) + + traversed = [] + for operation, raw in calls: + if not os.path.isabs(raw): + continue + candidate = os.path.normcase(os.path.abspath(raw)) + if candidate == outside_root or candidate.startswith(outside_root + os.path.sep): + traversed.append((operation, raw)) + assert traversed == [] + + +def test_recursive_workspace_settings_project_and_keep_their_deep_value( + native_tree, cyclic_gc_quiesced +): + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + depth = sys.getrecursionlimit() * 3 // 5 + raw = b'{"deep":' + b"[" * depth + b"0" + b"]" * depth + b"}" + settings = project / ".kiro/settings/cli.json" + settings.parent.mkdir(parents=True) + settings.write_bytes(raw) + + projected = projection.prepare_native_skill_projection(project) + + assert projected is not None + assert list(agents.glob(f"{projection.NATIVE_SKILL_ALIAS_PREFIX}*.json")) + written = json.loads(settings.read_bytes()) + assert written[projection._MANAGED_SETTING] is True + assert projection._INHERIT_SOURCE in written + nested, levels = written["deep"], 0 + while isinstance(nested, list): + assert len(nested) == 1 + nested, levels = nested[0], levels + 1 + assert (levels, nested) == (depth, 0) + + +def test_too_deep_workspace_settings_leave_projection_file_unchanged( + native_tree, cyclic_gc_quiesced, caplog +): + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + depth = sys.getrecursionlimit() * 2 + raw = b'{"deep":' + b"[" * depth + b"0" + b"]" * depth + b"}" + with pytest.raises(RecursionError): + json.dumps(json.loads(raw), indent=2) + settings = project / ".kiro/settings/cli.json" + settings.parent.mkdir(parents=True) + settings.write_bytes(raw) + before = settings.read_bytes() + before_mtime = settings.stat().st_mtime_ns + + with caplog.at_level(logging.WARNING, logger=projection.logger.name): + assert projection.prepare_native_skill_projection(project) is None + assert settings.read_bytes() == before + assert settings.stat().st_mtime_ns == before_mtime + assert "too deeply nested" in caplog.text + + +def test_recursive_workspace_settings_rollback_leaves_file_unchanged( + native_tree, cyclic_gc_quiesced +): + _home, _agents, project = native_tree + depth = sys.getrecursionlimit() * 2 + raw = ( + b'{"kirocrew.skillDiscovery.inheritFiles":true,' + b'"kirocrew.skillDiscovery.inheritSource":"local",' + b'"chat.disableInheritingDefaultResources":true,"deep":' + + b"[" * depth + + b"0" + + b"]" * depth + + b"}" + ) + with pytest.raises(RecursionError): + json.dumps(json.loads(raw), indent=2) + + settings = project / ".kiro/settings/cli.json" + settings.parent.mkdir(parents=True) + settings.write_bytes(raw) + before = settings.read_bytes() + before_mtime = settings.stat().st_mtime_ns + + assert projection.prepare_native_skill_projection(project, enabled=False) is None + assert settings.read_bytes() == before + assert settings.stat().st_mtime_ns == before_mtime + + +def test_kiro_link_inside_the_work_dir_projects_into_its_target(native_tree, monkeypatch): + """A ``.kiro`` link whose folder stays inside the work dir is followed, and rollback too.""" + from kiro_crew import platform_compat + + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + target_kiro = project / "shared" / ".kiro" + (target_kiro / "settings").mkdir(parents=True) + try: + platform_compat.symlink_or_junction(target_kiro, project / ".kiro") + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + target_settings = target_kiro / "settings" / "cli.json" + + prepared = projection.prepare_native_skill_projection(project) + assert prepared is not None + assert list(agents.glob(f"{projection.NATIVE_SKILL_ALIAS_PREFIX}*.json")) + written = json.loads(target_settings.read_text(encoding="utf-8")) + assert written["chat.disableInheritingDefaultResources"] is True + assert (target_kiro / "settings" / CLI_SETTINGS_LOCK_NAME).is_file() + assert platform_compat.is_link_or_junction(project / ".kiro") + + monkeypatch.setenv("KIROCREW_NATIVE_SKILL_PROJECTION", "0") + assert projection.prepare_native_skill_projection(project) is None + assert json.loads(target_settings.read_text(encoding="utf-8")) == {} + + +def test_kiro_link_leaving_the_work_dir_skips_projection(native_tree, tmp_path, caplog): + """A ``.kiro`` link whose folder leaves the work dir is refused: no alias, nothing written.""" + from kiro_crew import platform_compat + + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + outside_kiro = tmp_path / "outside" / ".kiro" + (outside_kiro / "settings").mkdir(parents=True) + outside_settings = outside_kiro / "settings" / "cli.json" + before = b'{"token": "SECRET"}' + outside_settings.write_bytes(before) + try: + platform_compat.symlink_or_junction(outside_kiro, project / ".kiro") + except (OSError, NotImplementedError) as exc: + pytest.fail(f"symlink is unavailable: {exc}") + + assert projection.prepare_native_skill_projection(project) is None + assert not list(agents.glob(f"{projection.NATIVE_SKILL_ALIAS_PREFIX}*.json")) + assert outside_settings.read_bytes() == before + assert not (outside_kiro / "settings" / CLI_SETTINGS_LOCK_NAME).exists() + + with caplog.at_level(logging.WARNING): + assert projection.prepare_native_skill_projection(project, enabled=False) is None + assert any("rollback" in record.getMessage() for record in caplog.records) + assert outside_settings.read_bytes() == before diff --git a/test/test_native_skill_projection_lease_scan.py b/test/test_native_skill_projection_lease_scan.py index 40b28a78f37..f312319be4a 100644 --- a/test/test_native_skill_projection_lease_scan.py +++ b/test/test_native_skill_projection_lease_scan.py @@ -534,7 +534,7 @@ def unavailable(_work_dir): raise OSError("test settings lock unavailable") yield - monkeypatch.setattr(projection, "workspace_cli_settings_lock", unavailable) + monkeypatch.setattr(projection, "locked_workspace_cli_settings", unavailable) assert projection.prepare_native_skill_projection(project) is None assert not list(agents.glob(f"{projection.NATIVE_SKILL_ALIAS_PREFIX}*.json")) diff --git a/test/test_native_skill_projection_pinned_settings.py b/test/test_native_skill_projection_pinned_settings.py new file mode 100644 index 00000000000..86e0ced438a --- /dev/null +++ b/test/test_native_skill_projection_pinned_settings.py @@ -0,0 +1,244 @@ +"""Descriptor-pinned workspace cli.json reads and writes for native skill projection.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from types import SimpleNamespace + +import native_skill_projection_helpers +import pytest + +from conftest import requires_symlinks +from kiro_crew import atomic_write as atomic_write_module +from kiro_crew.acp import skill_projection as projection +from kiro_crew.workspace_cli_settings import CLI_JSON_REFUSED + +native_tree = native_skill_projection_helpers.native_tree + +requires_pinned_replace = pytest.mark.skipif( + not atomic_write_module.pinned_parent_replace_supported(), + reason="descriptor-relative replacement is unavailable", +) +_OUTSIDE_SENTINEL = b'{"outside": "unchanged"}' + + +def _outside_cli_json(tmp_path: Path) -> Path: + outside = tmp_path / "outside" + outside.mkdir() + target = outside / "cli.json" + target.write_bytes(_OUTSIDE_SENTINEL) + return target + + +def _workspace_cli_json(project: Path) -> Path: + cli_json = project / ".kiro" / "settings" / "cli.json" + cli_json.parent.mkdir(parents=True, exist_ok=True) + return cli_json + + +def _hardlinked_cli_json(project: Path, tmp_path: Path) -> tuple[Path, Path]: + """Plant a workspace cli.json that is a hardlink to a file outside the work dir.""" + target = _outside_cli_json(tmp_path) + cli_json = _workspace_cli_json(project) + os.link(target, cli_json) + return cli_json, target + + +def _symlinked_cli_json(project: Path, tmp_path: Path) -> tuple[Path, Path]: + """Plant a workspace cli.json that is a symlink to a file outside the work dir.""" + target = _outside_cli_json(tmp_path) + cli_json = _workspace_cli_json(project) + cli_json.symlink_to(target) + return cli_json, target + + +refused_cli_json = pytest.mark.parametrize( + ("plant", "symlinked"), + [ + (_hardlinked_cli_json, False), + pytest.param(_symlinked_cli_json, True, marks=requires_symlinks), + ], + ids=["hardlink", "outside-symlink"], +) + + +def _replace_settings_with_outside_link(project: Path, tmp_path: Path) -> tuple[Path, Path]: + settings = project / ".kiro" / "settings" + held_settings = project / ".kiro" / "settings-held" + outside = tmp_path / "outside" + outside.mkdir() + (outside / "cli.json").write_bytes(_OUTSIDE_SENTINEL) + settings.rename(held_settings) + settings.symlink_to(outside, target_is_directory=True) + return held_settings, outside + + +@requires_symlinks +@requires_pinned_replace +def test_projection_refuses_settings_folder_swap_before_publish( + native_tree, tmp_path, monkeypatch, caplog +): + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + cli_json = project / ".kiro" / "settings" / "cli.json" + cli_json.parent.mkdir(parents=True) + original = b'{"operator.setting": true}' + cli_json.write_bytes(original) + real_carry = projection.carry_effort_ownership + swapped: list[tuple[Path, Path]] = [] + + def swap_before_publish(document, file_mtime): + if not swapped: + swapped.append(_replace_settings_with_outside_link(project, tmp_path)) + return real_carry(document, file_mtime) + + monkeypatch.setattr(projection, "carry_effort_ownership", swap_before_publish) + with caplog.at_level(logging.WARNING, logger="kiro_crew.acp.skill_projection"): + prepared = projection.prepare_native_skill_projection(project) + + assert prepared is None + assert swapped + held_settings, outside = swapped[0] + assert (outside / "cli.json").read_bytes() == _OUTSIDE_SENTINEL + assert (held_settings / "cli.json").read_bytes() == original + assert "workspace settings or lease lock unavailable" in caplog.text + + +@requires_symlinks +@requires_pinned_replace +def test_rollback_refuses_settings_folder_swap_before_restore( + native_tree, tmp_path, monkeypatch, caplog +): + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + prepared = projection.prepare_native_skill_projection(project) + assert prepared is not None + cli_json = project / ".kiro" / "settings" / "cli.json" + original = cli_json.read_bytes() + real_carry = projection.carry_effort_ownership + swapped: list[tuple[Path, Path]] = [] + + def swap_before_restore(document, file_mtime): + if not swapped: + swapped.append(_replace_settings_with_outside_link(project, tmp_path)) + return real_carry(document, file_mtime) + + monkeypatch.setattr(projection, "carry_effort_ownership", swap_before_restore) + with caplog.at_level(logging.WARNING, logger="kiro_crew.acp.skill_projection"): + assert projection.prepare_native_skill_projection(project, enabled=False) is None + + assert swapped + held_settings, outside = swapped[0] + assert (outside / "cli.json").read_bytes() == _OUTSIDE_SENTINEL + assert (held_settings / "cli.json").read_bytes() == original + assert "workspace settings unavailable during rollback" in caplog.text + + +@requires_pinned_replace +@refused_cli_json +def test_refused_cli_json_skips_the_projection(native_tree, tmp_path, caplog, plant, symlinked): + """A cli.json the pinned read refuses costs the spawn its projection, not the spawn.""" + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + cli_json, target = plant(project, tmp_path) + + with caplog.at_level(logging.WARNING, logger="kiro_crew.acp.skill_projection"): + assert projection.prepare_native_skill_projection(project, enabled=True) is None + + assert CLI_JSON_REFUSED in caplog.text + assert not list(agents.glob(f"{projection.NATIVE_SKILL_ALIAS_PREFIX}*.json")) + assert cli_json.read_bytes() == _OUTSIDE_SENTINEL + assert target.read_bytes() == _OUTSIDE_SENTINEL + assert cli_json.is_symlink() is symlinked + + +@requires_pinned_replace +@refused_cli_json +def test_refused_cli_json_skips_the_rollback(native_tree, tmp_path, caplog, plant, symlinked): + """The same refusal on the rollback path leaves the file to whoever owns it.""" + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + cli_json, target = plant(project, tmp_path) + + with caplog.at_level(logging.WARNING, logger="kiro_crew.acp.skill_projection"): + assert projection.prepare_native_skill_projection(project, enabled=False) is None + + assert CLI_JSON_REFUSED in caplog.text + assert cli_json.read_bytes() == _OUTSIDE_SENTINEL + assert target.read_bytes() == _OUTSIDE_SENTINEL + assert cli_json.is_symlink() is symlinked + + +@pytest.mark.parametrize("enabled", [True, False], ids=["projection", "rollback"]) +@refused_cli_json +def test_by_name_floor_refuses_linked_cli_json( + native_tree, tmp_path, monkeypatch, caplog, plant, symlinked, enabled +): + """The by-name floor refuses the same linked workspace files as the pinned path.""" + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + cli_json, target = plant(project, tmp_path) + original_cli = os.lstat(cli_json) + original_target = os.stat(target) + monkeypatch.setattr(atomic_write_module, "pinned_parent_replace_supported", lambda: False) + + with caplog.at_level(logging.WARNING, logger="kiro_crew.acp.skill_projection"): + assert projection.prepare_native_skill_projection(project, enabled=enabled) is None + + assert CLI_JSON_REFUSED in caplog.text + assert not list(agents.glob(f"{projection.NATIVE_SKILL_ALIAS_PREFIX}*.json")) + assert cli_json.read_bytes() == _OUTSIDE_SENTINEL + assert target.read_bytes() == _OUTSIDE_SENTINEL + current_cli = os.lstat(cli_json) + current_target = os.stat(target) + assert (current_cli.st_dev, current_cli.st_ino, current_cli.st_nlink) == ( + original_cli.st_dev, + original_cli.st_ino, + original_cli.st_nlink, + ) + assert (current_target.st_dev, current_target.st_ino, current_target.st_nlink) == ( + original_target.st_dev, + original_target.st_ino, + original_target.st_nlink, + ) + assert cli_json.is_symlink() is symlinked + if not symlinked: + assert os.path.samefile(cli_json, target) + + +@requires_pinned_replace +def test_locked_cli_json_that_keeps_changing_skips_projection(native_tree, monkeypatch, caplog): + """An exhausted locked metadata race skips projection instead of escaping.""" + _home, agents, project = native_tree + (agents / "custom.json").write_text('{"name":"custom"}', encoding="utf-8") + cli_json = _workspace_cli_json(project) + original = b'{"operator.setting": true}' + cli_json.write_bytes(original) + real_reader = projection.read_pinned_cli_json + calls: list[Path] = [] + + def changed_reader(settings_fd, path, *, max_bytes): + result = real_reader(settings_fd, path, max_bytes=max_bytes) + assert result is not None + raw, before_read, after_read = result + calls.append(path) + changed_after = SimpleNamespace( + st_dev=after_read.st_dev, + st_ino=after_read.st_ino, + st_size=after_read.st_size, + st_mtime=after_read.st_mtime, + st_mtime_ns=after_read.st_mtime_ns + 1, + st_ctime_ns=after_read.st_ctime_ns, + ) + return raw, before_read, changed_after + + monkeypatch.setattr(projection, "read_pinned_cli_json", changed_reader) + with caplog.at_level(logging.WARNING, logger="kiro_crew.acp.skill_projection"): + assert projection.prepare_native_skill_projection(project, enabled=True) is None + + assert calls == [cli_json, cli_json] + assert "changed while it was read" in caplog.text + assert not list(agents.glob(f"{projection.NATIVE_SKILL_ALIAS_PREFIX}*.json")) + assert cli_json.read_bytes() == original diff --git a/test/test_per_runtime_process_resources.py b/test/test_per_runtime_process_resources.py index f09584c2c79..7eb7a39f903 100644 --- a/test/test_per_runtime_process_resources.py +++ b/test/test_per_runtime_process_resources.py @@ -23,6 +23,7 @@ from unittest.mock import patch import pytest +from test_effort import _read_cli_overlay import kiro_crew.sandbox as sb @@ -376,7 +377,7 @@ def test_the_overlay_path_is_derived_from_the_work_dir_alone(self, tmp_path): session: two sessions on one runtime share the work directory and therefore this file, and kiro-cli reads it at spawn. """ - from kiro_crew.providers.acp import _read_cli_overlay, _write_cli_overlay + from kiro_crew.providers.acp import _write_cli_overlay _write_cli_overlay(tmp_path, "claude-sonnet-4", "high") written = tmp_path / ".kiro" / "settings" / "cli.json" @@ -386,7 +387,7 @@ def test_the_overlay_path_is_derived_from_the_work_dir_alone(self, tmp_path): def test_a_second_write_for_one_model_replaces_the_first(self, tmp_path): """One entry per model per work dir: the last writer is what a respawn reads.""" - from kiro_crew.providers.acp import _read_cli_overlay, _write_cli_overlay + from kiro_crew.providers.acp import _write_cli_overlay _write_cli_overlay(tmp_path, "claude-sonnet-4", "high") _write_cli_overlay(tmp_path, "claude-sonnet-4", "low") diff --git a/test/test_session.py b/test/test_session.py index 7d2e49f2677..b0595201120 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -27,7 +27,7 @@ from kiro_crew.messaging.link import ChannelLink from kiro_crew.session import BACKGROUND_KEY, CONSOLIDATE_KEY, HEARTBEAT_KEY, SessionManager from kiro_crew.start_priority import StartPriority -from kiro_crew.testing.wait import await_until +from kiro_crew.testing.wait import async_wait_until, await_until #: Ceiling for a wait that only fails on a hang: these steps finish in #: milliseconds, so a test fails by name instead of losing the xdist worker. @@ -434,6 +434,66 @@ async def _start(): for t in pending: t.cancel() + @pytest.mark.asyncio + async def test_a_cold_start_race_loser_waiting_on_the_winner_leaves_the_basis_unlocked( + self, cfg + ): + """The race loser shuts its duplicate down and then waits on the + winner's live lease, which can last a whole turn. It reads no effort + basis from there, so an effort pick made meanwhile is not refused as if + a session were starting.""" + start_gate = asyncio.Event() + both_started = asyncio.Event() + started = 0 + + def factory(session_key=None, agent=None, channel_id=None, **kwargs): + m = AsyncMock() + + async def _start(): + nonlocal started + started += 1 + if started == 2: + both_started.set() + await await_until(start_gate, what="the test releasing both cold starts") + + m.start = _start + m.shutdown = AsyncMock() + m.disown_work_dir = MagicMock() + m.is_process_alive = lambda: True + m.is_alive = lambda: True + m.context_usage_pct = lambda: 0.0 + return m + + mgr = SessionManager(cfg, provider_factory=factory) + tasks = { + asyncio.create_task(mgr.get_or_create("A")), + asyncio.create_task(mgr.get_or_create("A")), + } + try: + await await_until(both_started, what="both cold starts entering start()") + assert mgr.effort_basis_locked("A") is True + start_gate.set() + + winner = await await_until( + asyncio.ensure_future(asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)), + what="the race winner registering", + ) + done, pending = winner + assert len(done) == 1 and len(pending) == 1 + await async_wait_until( + lambda: not mgr.effort_basis_locked("A"), + describe=lambda: "the race loser still locks the effort basis", + ) + assert mgr._has_allocation_reservation("A") is True + assert not next(iter(pending)).done() + finally: + start_gate.set() + for task in tasks: + task.cancel() + await await_until( + asyncio.gather(*tasks, return_exceptions=True), what="cold-start race cleanup" + ) + @pytest.mark.asyncio async def test_same_session_still_serializes(self, cfg): """Sanity: the per-session semaphore still serializes the SAME key — diff --git a/test/test_session_allocation_bindings.py b/test/test_session_allocation_bindings.py index a32b19072fa..ae8add1e66a 100644 --- a/test/test_session_allocation_bindings.py +++ b/test/test_session_allocation_bindings.py @@ -74,10 +74,12 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``), so tes "_parent_runtime_kwargs": ("function", False, "f408fa261a2b", "companions"), "_reacquire_and_validate": ("function", True, "c69e8f69c8aa", "get_or_create"), "_refuse_if_ending": ("function", False, "4fa619d0b3bc", "facade"), + "_release_explicit_effort_default": ("function", False, "958babeb61c8", "facade"), "_release_reacquired": ("function", False, "a6707a152144", "get_or_create"), "_remember_capability_failure": ("function", False, "6efcfdf79280", "get_or_create"), "_remove_reservation_cancellation_drained": ("function", True, "d02e096bed1e", "get_or_create"), "_remove_reservation_now": ("function", False, "d02e096bed1e", "get_or_create"), + "_reserve_explicit_effort_default": ("function", False, "1eda252b53bd", "facade"), "_resolve_agent_model": ("function", False, "e7a565bb1ba0", "facade"), "_runtime_pid": ("staticmethod", False, "9a018c130ad5", "companions"), "_safe_cleanup": ("function", True, "76a7c030508f", "facade"), @@ -112,6 +114,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``), so tes "get_provider": ("function", False, "c66f77f7d253", "facade"), "get_subagent_runtime": ("function", True, "23c9947d4e41", "companions"), "has_allocation_reservation": ("function", False, "1eda252b53bd", "facade"), + "has_cold_start_reservation": ("function", False, "1eda252b53bd", "facade"), "has_session": ("function", False, "1eda252b53bd", "facade"), "inbound_callback_count": ("property", False, "fa6cfecd5574", "facade"), "is_busy": ("function", False, "1eda252b53bd", "facade"), @@ -395,6 +398,7 @@ def test_each_owner_names_the_fields_it_writes_and_reads(owner: str) -> None: "SessionClosingError", "SessionEndingError", "SpeculativeResumeRefused", + "_WarmPoolEffortRewriteDiscard", ) diff --git a/test/test_session_bindings.py b/test/test_session_bindings.py index a85e8b6e9d6..2b9ae923d81 100644 --- a/test/test_session_bindings.py +++ b/test/test_session_bindings.py @@ -88,6 +88,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "_draining_bg_runtimes": ("property", False, "eb482a8b1e3b", "state_views"), "_draining_subagent_runtimes": ("property", False, "eb482a8b1e3b", "state_views"), "_eager_respawn": ("function", True, "958babeb61c8", "teardown"), + "_effort_intent_writes_in_flight": ("function", False, "dfeea3d0cb81", "turn_context"), "_ensure_background": ("function", True, "ffb4efadb2a1", "runtimes"), "_ensure_cleanup_task": ("function", False, "b60c5fea256e", "sweeps"), "_evict_stale_session": ("function", True, "70690b0be258", "claims"), @@ -114,6 +115,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "_lifecycle_state_boundary": ("function", False, "4b08dece16e8", "state_views"), "_lock": ("property", False, "fd738ff11600", "state_views"), "_mark_identity_epoch": ("function", False, "b60c5fea256e", "runtimes"), + "_may_predate_effort_overlay_rewrite": ("function", False, "a476c90dfc62", "runtimes"), "_on_compacted": ("property", False, "863b11bcd58c", "state_views"), "_on_config_change": ("function", True, "999e37524dc3", "config_apply"), "_on_recycled": ("property", False, "67238bb9d853", "state_views"), @@ -164,6 +166,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "_suppress_replay": ("property", False, "67a56d539800", "state_views"), "_sweep_warm_pool_once": ("function", True, "b60c5fea256e", "runtimes"), "_sync_autocompact_pct": ("function", False, "b60c5fea256e", "config_apply"), + "_take_claim_spawn_time": ("function", False, "708a4dc2d50c", "runtimes"), "_trigger_compaction": ("function", False, "ea34ffbc5059", "turn_context"), "_update_pause_owned": ("property", False, "a6632a57e602", "state_views"), "_warm_pool": ("property", False, "c1bd5834bcdd", "state_views"), @@ -216,9 +219,13 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "drain_all_providers": ("function", True, "f5423acdeebc", "teardown"), "drain_warm_pool": ("function", True, "f5423acdeebc", "runtimes"), "effective_autocompact_pct": ("function", False, "c61b0946c307", "turn_context"), + "effort_basis_locked": ("function", False, "1eda252b53bd", "turn_context"), + "effort_intent_write": ("function", False, "e1e3936a7475", "turn_context"), "end_children_for": ("function", True, "958babeb61c8", "teardown"), "ending_key": ("function", False, "e1e3936a7475", "claims"), "enqueue": ("function", False, "d3c6da093136", "claims"), + "explicit_effort_default_pending": ("function", False, "1eda252b53bd", "turn_context"), + "fence_effort_overlay_rewrite": ("function", False, "08ba78a8a6e0", "runtimes"), "fence_update_restart": ("function", False, "a6632a57e602", "claims"), "final_drain_started": ("property", False, "a6632a57e602", "state_views"), "find_key_by_sid": ("function", False, "7f231b93851d", "links"), @@ -243,6 +250,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "get_thread": ("function", False, "4b576121a346", "links"), "has_mirror_row": ("function", False, "1eda252b53bd", "links"), "has_session": ("function", False, "1eda252b53bd", "claims"), + "hold_explicit_effort_default": ("function", False, "01799f94def1", "turn_context"), "identity_sweep_waiting_on": ("property", False, "9d5ab4ea1828", "state_views"), "inbound_callback_count": ("property", False, "fa6cfecd5574", "state_views"), "is_busy": ("function", False, "1eda252b53bd", "claims"), @@ -308,6 +316,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "set_compact_callback": ("function", False, "6f1bae527bd9", "turn_context"), "set_compacting_callback": ("function", False, "2aed1f018edf", "turn_context"), "set_continuable_fallback": ("function", False, "9129b57ccbd2", "claims"), + "set_explicit_effort_default": ("function", False, "076de2d36343", "turn_context"), "set_injection_probe": ("function", False, "f6fd6f250ecd", "sweeps"), "set_lifecycle_turn_active": ("function", False, "3656499fe52f", "claims"), "set_mirror_link": ("function", False, "e65f668f550c", "links"), @@ -332,6 +341,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "unlanded_marker_pending": ("function", False, "1eda252b53bd", "turn_context"), "unmark_continuable": ("function", False, "958babeb61c8", "claims"), "update_restart_fenced": ("property", False, "a6632a57e602", "state_views"), + "wait_for_effort_intent_writes": ("function", True, "958babeb61c8", "turn_context"), "warm_providers": ("function", False, "6d42d4fe7cc2", "runtimes"), "widen_cold_start_queues": ("function", False, "b60c5fea256e", "config_apply"), } @@ -401,6 +411,7 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s "_CHANNEL_PREFIX", "_COMPACT_RESULT_WAIT_FLOOR_SECS", "_CompactCallback", + "_EffortIntentWrites", "_GEN_SUFFIX_RE", "_MAX_ORIGIN_LINKS", "_PERSISTENT_KEYS", @@ -417,7 +428,13 @@ class binds it by name (``m = _owner.m``, or ``staticmethod(_owner.f)``); each s ) #: Classes that moved to an owner and must keep the facade's module name. -_MOVED_CLASSES = ("FirstTurnState", "_Session", "_CompactCallback", "_RecycleCallback") +_MOVED_CLASSES = ( + "FirstTurnState", + "_Session", + "_EffortIntentWrites", + "_CompactCallback", + "_RecycleCallback", +) _PACKAGE = "kiro_crew.session_parts" diff --git a/test/test_session_control_set_model.py b/test/test_session_control_set_model.py index a71eaa4765b..be2ab662868 100644 --- a/test/test_session_control_set_model.py +++ b/test/test_session_control_set_model.py @@ -388,7 +388,7 @@ def test_the_turn_arms_the_reset_for_a_live_or_spawning_session_only(): ] if turn_file.name != "__init__.py" ) - body = source[source.index("if apply_pending_model_pick(state, slot):") :] + body = source[source.index("if await _apply_pending_model_pick_at_turn_start(") :] body = body[: body.index("await _consume_pending_reset(state, slot)")] assert "arm_model_switch_reset(state, slot)" in body @@ -444,7 +444,7 @@ def test_the_turn_persists_a_committed_pick_before_it_acquires_a_session(): ] if turn_file.name != "__init__.py" ) - body = source[source.index("if apply_pending_model_pick(state, slot):") :] + body = source[source.index("if await _apply_pending_model_pick_at_turn_start(") :] body = body[: body.index("await _consume_pending_reset(state, slot)")] assert "await save_slot_off_loop(" in body assert "expected_history_key=_pick_history_key" in body @@ -471,7 +471,7 @@ def test_the_turn_applies_the_pick_before_it_acquires_a_session(): if turn_file.name != "__init__.py" ) body = source[source.index("async def _turn_admit(") :] - apply_at = body.index("apply_pending_model_pick(state, slot)") + apply_at = body.index("_apply_pending_model_pick_at_turn_start(") assert apply_at < body.index("await _consume_pending_reset(state, slot)") assert apply_at < body.index("_requested_model = slot.model") diff --git a/test/test_session_control_set_model_effort.py b/test/test_session_control_set_model_effort.py index 93a8fbcace2..d871ff21eea 100644 --- a/test/test_session_control_set_model_effort.py +++ b/test/test_session_control_set_model_effort.py @@ -10,14 +10,17 @@ import asyncio import json -from unittest.mock import MagicMock +from contextlib import contextmanager +from unittest.mock import AsyncMock, MagicMock import pytest from chat_test_helpers import _make_state from kiro_crew.acp.session_provider import AcpSessionProvider from kiro_crew.dashboard import session_control as sc -from kiro_crew.dashboard.chat_utils import slot_history_key +from kiro_crew.dashboard.chat_api import slot_effort +from kiro_crew.dashboard.chat_runner import _apply_pending_model_pick_at_turn_start +from kiro_crew.dashboard.chat_utils import effective_session_key, slot_history_key from kiro_crew.dashboard.handlers import session_control as handlers_sc from kiro_crew.mcp_dashboard import TABLE from kiro_crew.mcp_tools.dashboard_client import InMemoryDashboardClient @@ -48,6 +51,377 @@ def _pair(tmp_path): return state, state.get_or_create_slot("chat-1"), state.get_or_create_slot("chat-2") +def _track_explicit_default(state, *pending_keys: str, events: list[str] | None = None): + flags = {key: True for key in pending_keys} + + def pending(key: str) -> bool: + if key.startswith("slack:"): + return flags.get(key, False) or flags.get(key.removeprefix("slack:"), False) + return flags.get(key, False) + + def set_pending(key: str, value: bool) -> bool: + if events is not None: + events.append("clear" if not value else "set") + aliases = (key, key.removeprefix("slack:")) if key.startswith("slack:") else (key,) + for alias in aliases: + flags[alias] = value + return True + + @contextmanager + def effort_intent_write(_key: str): + if events is not None: + events.append("intent-enter") + try: + yield + finally: + if events is not None: + events.append("intent-exit") + + @contextmanager + def hold_default(_key: str): + if events is not None: + events.append("hold-enter") + try: + yield + finally: + if events is not None: + events.append("hold-exit") + + state.sessions.explicit_effort_default_pending = MagicMock(side_effect=pending) + state.sessions.set_explicit_effort_default = MagicMock(side_effect=set_pending) + state.sessions.effort_intent_write = MagicMock(side_effect=effort_intent_write) + state.sessions.hold_explicit_effort_default = MagicMock(side_effect=hold_default) + return flags + + +@pytest.mark.parametrize("pending_key", ["slack:1700000000.000100", "1700000000.000100"]) +def test_an_effort_commit_clears_both_spellings_of_a_folded_key(tmp_path, monkeypatch, pending_key): + from kiro_crew import session as session_module + + state, caller, target = _pair(tmp_path) + _set(state, caller, "chat-2", reasoning_effort="high") + cfg = MagicMock() + cfg.session.pool_size = 0 + cfg.session.pool_agent = "kirocrew" + cfg.session.pool_ttl_secs = 1800 + cfg.session.timeout_secs = 3600 + cfg.agent.default_agent = "" + cfg.agent.model = "auto" + monkeypatch.setenv("KIROCREW_HOME", str(tmp_path / "kirocrew-home")) + monkeypatch.setattr(session_module, "default_project_dir", lambda: str(tmp_path)) + manager = session_module.SessionManager(cfg, provider_factory=MagicMock()) + manager.set_explicit_effort_default(pending_key, True) + legacy_key = "1700000000.000100" + provider = MagicMock(has_active_turn=MagicMock(return_value=False)) + manager._allocation_boundary()._sessions[legacy_key] = provider + state.sessions = manager + target.linked_session_key = "slack:1700000000.000100" + monkeypatch.setattr(sc, "_another_alias_is_mid_turn", lambda *_args: False) + monkeypatch.setattr(sc, "authorize_target", lambda *_args, **_kwargs: target) + + assert sc.apply_pending_model_pick(state, target) is True + + assert manager.explicit_effort_default_pending("slack:1700000000.000100") is False + assert manager.explicit_effort_default_pending(legacy_key) is False + + +def test_a_model_only_commit_leaves_the_default_pending(tmp_path): + state, caller, target = _pair(tmp_path) + key = effective_session_key(target) + _track_explicit_default(state, key) + _set(state, caller, "chat-2", model="sonnet") + + assert sc.apply_pending_model_pick(state, target) is True + + assert state.sessions.explicit_effort_default_pending(key) is True + state.sessions.set_explicit_effort_default.assert_not_called() + + +def test_a_superseded_effort_half_leaves_the_default_pending(tmp_path): + state, caller, target = _pair(tmp_path) + key = effective_session_key(target) + _track_explicit_default(state, key) + _set(state, caller, "chat-2", reasoning_effort="high") + target.reasoning_effort = "low" + + assert sc.apply_pending_model_pick(state, target) is False + + assert state.sessions.explicit_effort_default_pending(key) is True + state.sessions.set_explicit_effort_default.assert_not_called() + + +def test_a_turn_start_gate_refusal_leaves_the_default_pending(tmp_path): + state, caller, target = _pair(tmp_path) + _set(state, caller, "chat-2", reasoning_effort="high") + target.linked_session_key = "slack:1700000000.000100" + key = effective_session_key(target) + _track_explicit_default(state, key) + + assert sc.apply_pending_model_pick(state, target) is False + + assert state.sessions.explicit_effort_default_pending(key) is True + state.sessions.set_explicit_effort_default.assert_not_called() + + +def test_a_same_level_effort_commit_clears_the_default_without_a_reset(tmp_path): + state, caller, target = _pair(tmp_path) + target.reasoning_effort = "medium" + key = effective_session_key(target) + _track_explicit_default(state, key) + _set(state, caller, "chat-2", reasoning_effort="medium") + + assert sc.apply_pending_model_pick(state, target) is False + + assert state.sessions.explicit_effort_default_pending(key) is False + + +def _record_saved_handler_intent(state, key: str, pending: bool, *, save_succeeds=True): + """Drive the effort handler's public flag, save-result, and generation effects.""" + prior = state.sessions.explicit_effort_default_pending(key) + state.sessions.set_explicit_effort_default(key, pending) + if not save_succeeds: + state.sessions.set_explicit_effort_default(key, prior) + return + if prior != pending: + slot_effort._bump_session_effort_intent_slots(state, key) + + +def _alias_pair(tmp_path, monkeypatch, *, default_pending: bool): + state, caller, target = _pair(tmp_path) + alias = state.get_or_create_slot("chat-alias") + session_key = "slack:1700000000.000100" + target.linked_session_key = session_key + alias.linked_session_key = session_key + _track_explicit_default(state, *(session_key,) if default_pending else ()) + monkeypatch.setattr(sc, "authorize_target", lambda *_args, **_kwargs: target) + monkeypatch.setattr(sc, "_another_alias_is_mid_turn", lambda *_args: False) + return state, caller, target, alias, session_key + + +def test_a_saved_default_on_an_alias_supersedes_the_calls_effort_half(tmp_path, monkeypatch): + state, caller, target, _alias, key = _alias_pair(tmp_path, monkeypatch, default_pending=False) + target.reasoning_effort = "low" + _set(state, caller, "chat-2", reasoning_effort="high") + audits: list[dict] = [] + monkeypatch.setattr(sc, "_audit", lambda **kwargs: audits.append(kwargs)) + + _record_saved_handler_intent(state, key, True) + + assert sc.apply_pending_model_pick(state, target) is False + assert target.reasoning_effort == "low" + assert state.sessions.explicit_effort_default_pending(key) is True + assert any( + audit.get("detail", {}).get("code") == "superseded_by_newer_effort" for audit in audits + ) + + +def test_a_saved_level_on_an_alias_supersedes_the_calls_effort_half(tmp_path, monkeypatch): + state, caller, target, _alias, key = _alias_pair(tmp_path, monkeypatch, default_pending=True) + target.reasoning_effort = "low" + _set(state, caller, "chat-2", reasoning_effort="high") + + _record_saved_handler_intent(state, key, False) + + assert sc.apply_pending_model_pick(state, target) is False + assert target.reasoning_effort == "low" + assert state.sessions.explicit_effort_default_pending(key) is False + + +def test_an_alias_level_without_a_pending_default_does_not_supersede_the_call( + tmp_path, monkeypatch +): + state, caller, target, _alias, key = _alias_pair(tmp_path, monkeypatch, default_pending=False) + target.reasoning_effort = "low" + _set(state, caller, "chat-2", reasoning_effort="high") + + _record_saved_handler_intent(state, key, False) + + assert sc.apply_pending_model_pick(state, target) is True + assert target.reasoning_effort == "high" + + +def test_a_failed_alias_intent_save_does_not_supersede_the_call(tmp_path, monkeypatch): + state, caller, target, _alias, key = _alias_pair(tmp_path, monkeypatch, default_pending=True) + target.reasoning_effort = "low" + _set(state, caller, "chat-2", reasoning_effort="high") + + _record_saved_handler_intent(state, key, False, save_succeeds=False) + + assert sc.apply_pending_model_pick(state, target) is True + assert target.reasoning_effort == "high" + assert state.sessions.explicit_effort_default_pending(key) is False + + +def test_the_turn_saves_a_cleared_default_before_acquiring_a_session(tmp_path): + state, caller, target = _pair(tmp_path) + key = effective_session_key(target) + events: list[str] = [] + _track_explicit_default(state, key, events=events) + _set(state, caller, "chat-2", reasoning_effort="high") + + async def flush() -> None: + events.append("save") + + async def acquire(*_args, **_kwargs): + events.append("acquire") + return MagicMock(), True, None + + state.sessions.aflush = MagicMock(side_effect=flush) + state.sessions.get_or_create = MagicMock(side_effect=acquire) + + async def run() -> bool: + reset_needed = await _apply_pending_model_pick_at_turn_start( + state, target, sc.apply_pending_model_pick + ) + await state.sessions.get_or_create(key) + return reset_needed + + assert asyncio.run(run()) is True + assert events == [ + "intent-enter", + "hold-enter", + "clear", + "save", + "hold-exit", + "intent-exit", + "acquire", + ] + + +def test_a_failed_turn_start_clear_save_defers_the_pick(tmp_path, caplog, monkeypatch): + state, caller, target = _pair(tmp_path) + target.model = "opus" + target.jev_route = True + target.reasoning_effort = "low" + key = effective_session_key(target) + flags = _track_explicit_default(state, key) + out = _set(state, caller, "chat-2", model="sonnet", reasoning_effort="high") + pick = target._pending_model_pick + before = ( + target.model, + target.reasoning_effort, + target.jev_route, + target._model_pick_gen, + target._effort_pick_gen, + ) + audits: list[dict] = [] + monkeypatch.setattr(sc, "_audit", lambda **kwargs: audits.append(kwargs)) + state.sessions.aflush = AsyncMock(side_effect=OSError("disk full")) + + reset_needed = asyncio.run( + _apply_pending_model_pick_at_turn_start(state, target, sc.apply_pending_model_pick) + ) + + assert reset_needed is False + assert ( + target.model, + target.reasoning_effort, + target.jev_route, + target._model_pick_gen, + target._effort_pick_gen, + ) == before + assert target._pending_model_pick is pick + assert flags[key] is True + assert "the pending pick waits for the next turn" in caplog.text + assert any( + audit.get("outcome") == "deferred" + and audit.get("detail", {}).get("code") == "effort_default_clear_unsaved" + for audit in audits + ) + + state.sessions.aflush = AsyncMock() + assert ( + asyncio.run( + _apply_pending_model_pick_at_turn_start(state, target, sc.apply_pending_model_pick) + ) + is True + ) + assert target._pending_model_pick is None + assert target.model == out["model"] + assert target.reasoning_effort == "high" + assert flags[key] is False + + +def test_a_cancelled_turn_start_clear_save_defers_the_pick(tmp_path): + state, caller, target = _pair(tmp_path) + target.model = "opus" + target.jev_route = True + target.reasoning_effort = "low" + key = effective_session_key(target) + flags = _track_explicit_default(state, key) + _set(state, caller, "chat-2", model="sonnet", reasoning_effort="high") + pick = target._pending_model_pick + before = ( + target.model, + target.reasoning_effort, + target.jev_route, + target._model_pick_gen, + target._effort_pick_gen, + ) + calls = 0 + + async def cancel_once() -> None: + nonlocal calls + calls += 1 + if calls == 1: + raise asyncio.CancelledError + + state.sessions.aflush = AsyncMock(side_effect=cancel_once) + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + _apply_pending_model_pick_at_turn_start(state, target, sc.apply_pending_model_pick) + ) + + assert ( + target.model, + target.reasoning_effort, + target.jev_route, + target._model_pick_gen, + target._effort_pick_gen, + ) == before + assert target._pending_model_pick is pick + assert flags[key] is True + assert calls == 2, "the restored flag gets one best-effort save" + + +def test_a_failed_turn_start_clear_keeps_its_row_while_restoring(tmp_path, monkeypatch): + from kiro_crew import session_map as session_map_module + from kiro_crew.session import SessionManager + from kiro_crew.session_map import SessionMap + + state, caller, target = _pair(tmp_path) + key = effective_session_key(target) + _set(state, caller, "chat-2", reasoning_effort="high") + monkeypatch.setenv("KIROCREW_HOME", str(tmp_path / "kirocrew-home")) + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + manager = SessionManager.__new__(SessionManager) + manager._session_map = SessionMap() + manager._fold_key = lambda raw: raw + manager.set_explicit_effort_default(key, True) + state.sessions = manager + monkeypatch.setattr(sc, "_another_alias_is_mid_turn", lambda *_args: False) + monkeypatch.setattr(sc, "authorize_target", lambda *_args, **_kwargs: target) + newcomer_was_refused = False + + async def fail_after_competitor() -> None: + nonlocal newcomer_was_refused + newcomer_was_refused = not manager.set_explicit_effort_default("dashboard:newcomer", True) + raise OSError("disk full") + + manager.aflush = fail_after_competitor + + assert ( + asyncio.run( + _apply_pending_model_pick_at_turn_start(state, target, sc.apply_pending_model_pick) + ) + is False + ) + assert newcomer_was_refused is True + assert manager.explicit_effort_default_pending(key) is True + assert manager.explicit_effort_default_pending("dashboard:newcomer") is False + + def test_an_effort_only_pick_keeps_the_model_and_commits_the_level(tmp_path): state, caller, target = _pair(tmp_path) target.model = "pinned-model" diff --git a/test/test_session_map_bindings.py b/test/test_session_map_bindings.py index 61ff04463f8..5251e03a1c0 100644 --- a/test/test_session_map_bindings.py +++ b/test/test_session_map_bindings.py @@ -57,6 +57,7 @@ class binds it by name -- ``m = _owner.m`` for one whose first parameter is ``se "_emit_unbind_audit": ("function", False, "6dee7c04ead0", "session_map"), "_ensure_entry": ("function", False, "2691c02c86a5", "session_map"), "_evict_rival_claimants": ("function", False, "549d1bba1f9a", "session_map_links"), + "_explicit_effort_default_rows_counted": ("function", False, "fa6cfecd5574", "session_map"), "_flush_async": ("function", True, "b60c5fea256e", "session_map"), "_inbound_binding": ("staticmethod", False, "e524cfbdf3b5", "session_map_links"), "_is_self_derived": ("staticmethod", False, "7a84b80958b0", "session_map_links"), @@ -66,12 +67,14 @@ class binds it by name -- ``m = _owner.m`` for one whose first parameter is ``se "_note_bind": ("function", False, "958babeb61c8", "session_map"), "_note_inbound_unbind": ("function", False, "0ca375d2aa08", "session_map"), "_rebuild_thread_index": ("function", False, "b60c5fea256e", "session_map_links"), + "_release_explicit_effort_default_holds": ("function", False, "7b245a039ea2", "session_map"), "_remove_entry": ("function", False, "27b949e9015d", "session_map"), "_repair_or_remove_stale": ("function", False, "958babeb61c8", "session_map"), "_resolve_alias": ("function", False, "6fa4015b9565", "session_map"), "_restore_dirty": ("function", False, "b60c5fea256e", "session_map_store"), "_save": ("function", False, "b60c5fea256e", "session_map_store"), "_serialize": ("function", False, "0b041d09502d", "session_map_store"), + "_take_explicit_effort_default_holds": ("function", False, "9b0384761f5f", "session_map"), "_take_pending_snapshot": ("function", False, "81eec0f2b49e", "session_map_store"), "_write": ("function", False, "b60c5fea256e", "session_map_store"), "_write_payload": ("function", False, "5f650127c38f", "session_map_store"), @@ -89,6 +92,7 @@ class binds it by name -- ``m = _owner.m`` for one whose first parameter is ``se "clear_slack_link_if": ("function", False, "c9d53f91d7af", "session_map_links"), "delete": ("function", False, "c52498e8a791", "session_map"), "delete_if_sid": ("function", False, "70e3d61c5764", "session_map"), + "explicit_effort_default_held": ("function", False, "1eda252b53bd", "session_map"), "find_key_by_sid": ("function", False, "08155f86f770", "session_map"), "find_mirror_sessions": ("function", False, "7b7ff4cef714", "session_map_links"), "flush": ("function", False, "b60c5fea256e", "session_map_store"), @@ -106,6 +110,7 @@ class binds it by name -- ``m = _owner.m`` for one whose first parameter is ``se "get_slack_link": ("function", False, "1ff511281524", "session_map_links"), "has_hint": ("function", False, "1eda252b53bd", "session_map"), "has_mirror_row": ("function", False, "1eda252b53bd", "session_map_links"), + "hold_explicit_effort_default": ("function", False, "6a7ccd8fcbf8", "session_map"), "is_mirror_paused": ("function", False, "8f19901e86b9", "session_map_links"), "is_slack_paused": ("function", False, "1eda252b53bd", "session_map_links"), "mapped_sid": ("function", False, "f1d40e624ae4", "session_map"), diff --git a/test/test_session_map_mirror.py b/test/test_session_map_mirror.py index 60679d2f3ce..668499cc346 100644 --- a/test/test_session_map_mirror.py +++ b/test/test_session_map_mirror.py @@ -17,6 +17,7 @@ from unittest.mock import MagicMock, patch import pytest +from session_map_mirror_helpers import session_map_fixture # noqa: F401 from kiro_crew.messaging.link import ( UNBIND_REASON_PRUNED_STALE, @@ -36,13 +37,6 @@ from kiro_crew.testing.wait import async_wait_until -@pytest.fixture() -def session_map(tmp_path): - """A SessionMap backed by a temp directory.""" - with patch("kiro_crew.session_map.config_dir", return_value=tmp_path): - yield SessionMap() - - def _manager_over(session_map): """A SessionManager wired to just this map. diff --git a/test/test_session_map_mirror_flags.py b/test/test_session_map_mirror_flags.py new file mode 100644 index 00000000000..740bb74b4b6 --- /dev/null +++ b/test/test_session_map_mirror_flags.py @@ -0,0 +1,319 @@ +"""SessionMap per-key flags: malformed persisted values and the explicit effort Default. + +A persisted ``flags`` value that is not a mapping reads as empty and is replaced +on the next write; only a literal ``True`` under ``EXPLICIT_EFFORT_DEFAULT_FLAG`` +is a pending retained Default, and the rows holding one are capped. +""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest +from session_map_mirror_helpers import session_map_fixture # noqa: F401 + +from kiro_crew import session_map as session_map_module +from kiro_crew.session import SessionManager +from kiro_crew.session_map import ( + EXPLICIT_EFFORT_DEFAULT_FLAG, + SUPPRESS_REPLAY_FLAG, + EffortDefaultRowRefused, + SessionMap, +) + + +class TestMalformedPersistedFlags: + """Malformed persisted flag values act as an empty mapping.""" + + @staticmethod + def _load_with_flags(tmp_path, flags): + key = "dashboard:malformed-flags" + (tmp_path / "session_map.json").write_text( + json.dumps({key: {"sid": "", "flags": flags}}), encoding="utf-8" + ) + with patch("kiro_crew.session_map.config_dir", return_value=tmp_path): + return key, SessionMap() + + @pytest.mark.parametrize("flags", [["not", "a", "mapping"], "not a mapping", 1]) + def test_get_flag_reads_malformed_persisted_flags_as_empty(self, tmp_path, flags): + key, session_map = self._load_with_flags(tmp_path, flags) + + assert session_map.get_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG) is False + assert session_map.get_flag(key, SUPPRESS_REPLAY_FLAG) is False + + @pytest.mark.parametrize("flags", [["not", "a", "mapping"], "not a mapping", 1]) + def test_set_flag_replaces_malformed_persisted_flags(self, tmp_path, flags): + key, session_map = self._load_with_flags(tmp_path, flags) + + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert session_map._data[key]["flags"] == {EXPLICIT_EFFORT_DEFAULT_FLAG: True} + + @pytest.mark.parametrize("flags", [["not", "a", "mapping"], "not a mapping", 1]) + def test_clearing_flag_removes_malformed_persisted_flags(self, tmp_path, flags): + key, session_map = self._load_with_flags(tmp_path, flags) + + session_map.set_flag(key, SUPPRESS_REPLAY_FLAG, False) + + assert "flags" not in session_map._data[key] + + +class TestExplicitEffortDefaultFlag: + @pytest.mark.parametrize( + ("value", "is_set"), + [ + ("false", False), + ("true", False), + (1, False), + (0, False), + (None, False), + ({}, False), + (True, True), + ], + ) + def test_only_literal_true_is_a_pending_retained_default( + self, session_map, monkeypatch, value, is_set + ): + key = "dashboard:explicit-default" + session_map._data[key] = {"sid": "", "flags": {EXPLICIT_EFFORT_DEFAULT_FLAG: value}} + manager = SessionManager.__new__(SessionManager) + manager._session_map = session_map + manager._fold_key = lambda candidate: candidate + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + + assert session_map.get_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG) is is_set + assert manager.explicit_effort_default_pending(key) is is_set + assert session_map._explicit_effort_default_rows_counted() == int(is_set) + if is_set: + with pytest.raises(EffortDefaultRowRefused): + session_map.set_flag("dashboard:next", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + assert session_map.prune() == 0 + assert key in session_map._data + else: + session_map.set_flag("dashboard:next", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + session_map.set_flag("dashboard:next", EXPLICIT_EFFORT_DEFAULT_FLAG, False) + assert session_map.prune() == 1 + assert key not in session_map._data + + def test_prune_preserves_a_sidless_explicit_default_intent(self, session_map): + key = "dashboard:explicit-default" + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert session_map.prune() == 0 + assert session_map.get_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG) is True + + # The row existed only to carry the flag, so clearing it removes the row + # at once rather than leaving it for a later prune. + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, False) + assert session_map.get_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG) is False + assert key not in session_map._data + assert session_map.prune() == 0 + + def test_clearing_the_flag_keeps_a_row_that_holds_other_state(self, session_map): + key = "dashboard:ran-before" + session_map.set(key, "sid-1") + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, False) + + assert session_map._data[key] == { + "sid": "sid-1", + "slack_thread_ts": None, + "slack_channel_id": None, + } + + def test_picks_on_new_keys_leave_no_row_behind(self, session_map, monkeypatch): + # Every row the flag creates is counted against its bound until the + # flag is cleared; a row left behind after that would be counted by no + # bound, one per new chat. + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + for index in range(5): + key = f"dashboard:new-chat-{index}" + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, False) + + assert not [key for key in session_map._data if key.startswith("dashboard:new-chat-")] + + def test_cap_refuses_a_new_default_row_without_writing(self, session_map, monkeypatch): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + session_map.set_flag("dashboard:first", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + with pytest.raises(EffortDefaultRowRefused) as exc_info: + session_map.set_flag("dashboard:second", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert exc_info.value.reason == "limit" + assert "dashboard:second" not in session_map._data + + def test_a_row_with_malformed_flags_does_not_break_the_bound(self, session_map): + session_map._data["dashboard:corrupt"] = {"sid": None, "flags": ["not", "an", "object"]} + + session_map.set_flag("dashboard:ok", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert session_map.get_flag("dashboard:ok", EXPLICIT_EFFORT_DEFAULT_FLAG) is True + + def test_a_held_row_stays_counted_and_its_key_arms_into_it(self, session_map, monkeypatch): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + session_map.set_flag("dashboard:spent", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + with session_map.hold_explicit_effort_default("dashboard:spent"): + session_map.set_flag("dashboard:spent", EXPLICIT_EFFORT_DEFAULT_FLAG, False) + # The row is still the spent key's, so a newcomer cannot take it... + with pytest.raises(EffortDefaultRowRefused) as exc_info: + session_map.set_flag("dashboard:newcomer", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + # ...and the spent key arms into it although the bound is full. + session_map.set_flag("dashboard:spent", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert exc_info.value.reason == "limit" + assert "dashboard:newcomer" not in session_map._data + assert session_map.get_flag("dashboard:spent", EXPLICIT_EFFORT_DEFAULT_FLAG) is True + with pytest.raises(EffortDefaultRowRefused): + session_map.set_flag("dashboard:newcomer", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + def test_a_released_hold_frees_the_row_its_key_left_clear(self, session_map, monkeypatch): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + session_map.set_flag("dashboard:spent", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + with session_map.hold_explicit_effort_default("dashboard:spent"): + session_map.set_flag("dashboard:spent", EXPLICIT_EFFORT_DEFAULT_FLAG, False) + + assert session_map.explicit_effort_default_held("dashboard:spent") is False + session_map.set_flag("dashboard:newcomer", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + assert session_map.get_flag("dashboard:newcomer", EXPLICIT_EFFORT_DEFAULT_FLAG) is True + + def test_a_hold_on_a_key_without_a_row_holds_nothing(self, session_map, monkeypatch): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + + with session_map.hold_explicit_effort_default("dashboard:never-flagged"): + assert session_map.explicit_effort_default_held("dashboard:never-flagged") is False + session_map.set_flag("dashboard:newcomer", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + # A hold adds no row, so it cannot let its key arm past the bound. + with pytest.raises(EffortDefaultRowRefused): + session_map.set_flag("dashboard:never-flagged", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + def test_overlapping_holds_count_one_row_until_the_last_ends(self, session_map, monkeypatch): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 2) + key = "dashboard:spent" + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + with session_map.hold_explicit_effort_default(key): + with session_map.hold_explicit_effort_default(key): + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, False) + session_map.set_flag("dashboard:second", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + assert session_map.explicit_effort_default_held(key) is True + with pytest.raises(EffortDefaultRowRefused): + session_map.set_flag("dashboard:third", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert session_map.explicit_effort_default_held(key) is False + session_map.set_flag("dashboard:third", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + def test_a_hold_is_released_when_its_block_raises(self, session_map): + key = "dashboard:spent" + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + with pytest.raises(RuntimeError, match="boom"): + with session_map.hold_explicit_effort_default(key): + raise RuntimeError("boom") + + assert session_map.explicit_effort_default_held(key) is False + + def test_a_hold_does_not_lift_the_key_bound(self, session_map): + from kiro_crew.session_map import PRIVACY_ROW_KEY_MAX + + # A row too long to admit can still arrive in a map file an older build wrote. + key = "x" * (PRIVACY_ROW_KEY_MAX + 1) + session_map._data[key] = {"sid": None, "flags": {EXPLICIT_EFFORT_DEFAULT_FLAG: True}} + + with session_map.hold_explicit_effort_default(key): + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, False) + with pytest.raises(EffortDefaultRowRefused) as exc_info: + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert exc_info.value.reason == "key_too_long" + + def test_manager_arms_again_the_spelling_its_hold_kept(self, session_map, monkeypatch): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + manager = SessionManager.__new__(SessionManager) + manager._session_map = session_map + manager._fold_key = lambda key: "dashboard:folded" + # Written while the key folded to itself, before a live session changed that. + session_map.set_flag("raw-key", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + with manager.hold_explicit_effort_default("raw-key"): + manager.set_explicit_effort_default("raw-key", False) + with pytest.raises(EffortDefaultRowRefused): + session_map.set_flag("dashboard:newcomer", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + assert manager.set_explicit_effort_default("raw-key", True) is True + + # Armed in the row the hold kept, so the bound still counts one row. + assert session_map.get_flag("raw-key", EXPLICIT_EFFORT_DEFAULT_FLAG) is True + assert session_map.get_flag("dashboard:folded", EXPLICIT_EFFORT_DEFAULT_FLAG) is False + assert manager.explicit_effort_default_pending("raw-key") is True + + def test_overlong_default_key_is_refused_without_writing(self, session_map): + from kiro_crew.session_map import PRIVACY_ROW_KEY_MAX + + key = "x" * (PRIVACY_ROW_KEY_MAX + 1) + with pytest.raises(EffortDefaultRowRefused) as exc_info: + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert exc_info.value.reason == "key_too_long" + assert key not in session_map._data + + def test_clearing_and_resetting_an_existing_default_row_is_not_refused( + self, session_map, monkeypatch + ): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + key = "dashboard:explicit-default" + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, False) + session_map.set_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG, True) + + assert session_map.get_flag(key, EXPLICIT_EFFORT_DEFAULT_FLAG) is True + + def test_manager_refuses_an_unretainable_default_row(self, session_map, monkeypatch): + from kiro_crew import session_map as session_map_module + + manager = SessionManager.__new__(SessionManager) + manager._session_map = session_map + manager._fold_key = lambda key: key + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 0) + + assert manager.set_explicit_effort_default("dashboard:refused", True) is False + assert "dashboard:refused" not in session_map._data + + def test_manager_reads_and_clears_folded_and_raw_spellings(self, session_map): + manager = SessionManager.__new__(SessionManager) + manager._session_map = session_map + manager._fold_key = lambda key: "dashboard:folded" + + manager.set_explicit_effort_default("raw-key", True) + assert session_map.get_flag("dashboard:folded", EXPLICIT_EFFORT_DEFAULT_FLAG) is True + assert session_map.get_flag("raw-key", EXPLICIT_EFFORT_DEFAULT_FLAG) is False + assert manager.explicit_effort_default_pending("raw-key") is True + + # Read like the replay-suppression flag: either spelling counts, and a + # clear removes both. + manager.set_explicit_effort_default("raw-key", False) + session_map.set_flag("raw-key", EXPLICIT_EFFORT_DEFAULT_FLAG, True) + assert manager.explicit_effort_default_pending("raw-key") is True + manager.set_explicit_effort_default("raw-key", False) + assert manager.explicit_effort_default_pending("raw-key") is False + assert session_map.get_flag("raw-key", EXPLICIT_EFFORT_DEFAULT_FLAG) is False diff --git a/test/test_session_pool.py b/test/test_session_pool.py index 2c46491ae31..319498ec72d 100644 --- a/test/test_session_pool.py +++ b/test/test_session_pool.py @@ -6,13 +6,15 @@ disabled pool) that never claim. How a provider leaves the pool and its process dies lives in ``test_session_pool_reaping.py``; how the pool refills after a factory reload or a defaults refresh, in which workspace, and around the fill lock in -``test_session_pool_refill.py``; the shared fixture and stand-ins in +``test_session_pool_refill.py``; how a pending explicit effort Default reaches a cold +start in ``test_session_pool_explicit_default.py``; the shared fixture and stand-ins in ``session_pool_helpers.py``. """ from __future__ import annotations import asyncio +import threading import time from concurrent.futures import ThreadPoolExecutor from unittest.mock import AsyncMock, MagicMock, patch @@ -27,6 +29,18 @@ from kiro_crew.acp.session_handle import WatchdogSettings from kiro_crew.start_priority import StartPriority +from kiro_crew.testing.wait import async_wait_until, default_timeout + + +async def _await_test(awaitable, what: str): + try: + async with asyncio.timeout(default_timeout()) as deadline: + return await awaitable + except TimeoutError as exc: + if not deadline.expired(): + raise + raise AssertionError(f"timed out waiting for {what}") from exc + # --------------------------------------------------------------------------- # _fill_warm_pool @@ -176,6 +190,60 @@ async def test_cancelled_error_cleans_up_via_finally(self): class TestLivenessDrainLoop: + @pytest.mark.asyncio + async def test_a_provider_queued_before_an_explicit_default_rewrite_is_discarded(self): + """It read cli.json at its own spawn, so it would run the entry just removed.""" + mgr, _ = _make_manager(pool_agent="kirocrew") + stale = _make_provider() + mgr._warm_pool.put_nowait((stale, time.monotonic())) + + with mgr.fence_effort_overlay_rewrite(): + pass + + assert await mgr._drain_and_claim("kirocrew") is None + stale.shutdown.assert_awaited_once() + + @pytest.mark.asyncio + async def test_no_provider_is_claimed_while_an_explicit_default_rewrite_is_in_progress(self): + """The file can change at any point in the write, so no queued runtime is known fresh.""" + mgr, _ = _make_manager(pool_agent="kirocrew") + queued = _make_provider() + mgr._warm_pool.put_nowait((queued, time.monotonic() + 3600)) + + with mgr.fence_effort_overlay_rewrite(): + assert await mgr._drain_and_claim("kirocrew") is None + + queued.shutdown.assert_awaited_once() + + @pytest.mark.asyncio + async def test_a_provider_spawned_during_an_explicit_default_rewrite_is_discarded( + self, manual_clock, monkeypatch + ): + """Its kiro-cli may have read the file before the write landed.""" + from kiro_crew import session_pool + + manual_clock.install(monkeypatch, session_pool) + mgr, _ = _make_manager(pool_agent="kirocrew") + spawned_during = _make_provider() + + with mgr.fence_effort_overlay_rewrite(): + spawned_after_the_rewrite_began = manual_clock.advance(0.01) + mgr._warm_pool.put_nowait((spawned_during, spawned_after_the_rewrite_began)) + + assert await mgr._drain_and_claim("kirocrew") is None + spawned_during.shutdown.assert_awaited_once() + + @pytest.mark.asyncio + async def test_a_provider_spawned_after_an_explicit_default_rewrite_is_claimed(self): + mgr, _ = _make_manager(pool_agent="kirocrew") + with mgr.fence_effort_overlay_rewrite(): + pass + fresh = _make_provider() + mgr._warm_pool.put_nowait((fresh, mgr._pool.state.effort_overlay_epoch + 0.001)) + + assert await mgr._drain_and_claim("kirocrew") is fresh + fresh.shutdown.assert_not_awaited() + @pytest.mark.asyncio async def test_dead_provider_discarded_healthy_used(self): """Dead providers are drained; first healthy one is used.""" @@ -453,6 +521,155 @@ async def test_claims_from_pool_when_agent_matches(self): mgr._schedule_replenish.assert_called_once() factory.assert_not_called() + @staticmethod + def _warm_acp_provider(): + from kiro_crew.providers.acp import AcpProvider + + pooled = _make_provider() + pooled.__class__ = AcpProvider + pooled.client = MagicMock() + pooled.client.resumed = False + pooled.client._session_id = "fake-sid" + return pooled + + async def _claim_waiting_at_model_resolution(self, mgr, monkeypatch): + entered = threading.Event() + release = threading.Event() + + def resolve_pool_model(_agent): + entered.set() + assert release.wait(timeout=5), "the test did not release model resolution" + return "custom-model" + + monkeypatch.setattr(mgr, "_resolve_agent_model", resolve_pool_model) + task = asyncio.create_task( + mgr.get_or_create("test-key", agent="kirocrew", model="custom-model") + ) + await async_wait_until( + entered.is_set, describe=lambda: "the warm claim never reached model resolution" + ) + return task, release + + @pytest.mark.asyncio + async def test_completed_effort_rewrite_after_claim_discards_before_registration( + self, monkeypatch + ): + mgr, factory = _make_manager(pool_agent="kirocrew") + pooled = self._warm_acp_provider() + mgr._warm_pool.put_nowait((pooled, time.monotonic())) + mgr._schedule_replenish = MagicMock() + discard = AsyncMock() + mgr._discard_pool_provider = discard + + task, release = await self._claim_waiting_at_model_resolution(mgr, monkeypatch) + with mgr.fence_effort_overlay_rewrite(): + pass + release.set() + provider, is_new, _ = await _await_test(task, "the warm claim") + + assert provider is not pooled and is_new + discard.assert_awaited_once_with(pooled, "Warm pool effort registration discard") + factory.assert_called_once() + mgr.release("test-key") + await mgr.reset("test-key") + + @pytest.mark.asyncio + async def test_cancelled_effort_rewrite_discard_releases_reservation(self, monkeypatch): + mgr, factory = _make_manager(pool_agent="kirocrew") + pooled = self._warm_acp_provider() + mgr._warm_pool.put_nowait((pooled, time.monotonic())) + mgr._schedule_replenish = MagicMock() + discard_entered = asyncio.Event() + + async def discard(provider, context): + assert provider is pooled + assert context == "Warm pool effort registration discard" + discard_entered.set() + await asyncio.Event().wait() + + mgr._discard_pool_provider = AsyncMock(side_effect=discard) + + task, release = await self._claim_waiting_at_model_resolution(mgr, monkeypatch) + with mgr.fence_effort_overlay_rewrite(): + pass + release.set() + await _await_test(discard_entered.wait(), "the warm-pool discard entering") + assert mgr.effort_basis_locked("test-key") is True + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await _await_test(task, "task") + + mgr._discard_pool_provider.assert_awaited_once_with( + pooled, "Warm pool effort registration discard" + ) + # This is the picker's turn_in_flight gate. A leaked reservation leaves it true. + assert mgr.effort_basis_locked("test-key") is False + assert mgr._allocation_boundary()._allocation_reservations == {} + + provider, is_new, _ = await mgr.get_or_create("test-key", agent="kirocrew") + assert provider is not pooled and is_new + factory.assert_called_once() + mgr.release("test-key") + await mgr.reset("test-key") + + @pytest.mark.asyncio + async def test_in_progress_effort_rewrite_after_claim_discards_before_registration( + self, monkeypatch + ): + mgr, factory = _make_manager(pool_agent="kirocrew") + pooled = self._warm_acp_provider() + mgr._warm_pool.put_nowait((pooled, time.monotonic())) + mgr._schedule_replenish = MagicMock() + discard = AsyncMock() + mgr._discard_pool_provider = discard + + task, release = await self._claim_waiting_at_model_resolution(mgr, monkeypatch) + with mgr.fence_effort_overlay_rewrite(): + release.set() + provider, is_new, _ = await _await_test(task, "the warm claim") + + assert provider is not pooled and is_new + discard.assert_awaited_once_with(pooled, "Warm pool effort registration discard") + factory.assert_called_once() + mgr.release("test-key") + await mgr.reset("test-key") + + @pytest.mark.asyncio + async def test_warm_claim_without_effort_rewrite_still_registers(self, monkeypatch): + mgr, factory = _make_manager(pool_agent="kirocrew") + pooled = self._warm_acp_provider() + mgr._warm_pool.put_nowait((pooled, time.monotonic())) + mgr._schedule_replenish = MagicMock() + + task, release = await self._claim_waiting_at_model_resolution(mgr, monkeypatch) + release.set() + provider, is_new, _ = await _await_test(task, "the warm claim") + + assert provider is pooled and is_new + pooled.shutdown.assert_not_awaited() + factory.assert_not_called() + mgr.release("test-key") + await mgr.reset("test-key") + + @pytest.mark.asyncio + async def test_claim_model_resolution_failure_removes_claimed_spawn_time(self, monkeypatch): + mgr, factory = _make_manager(pool_agent="kirocrew") + pooled = self._warm_acp_provider() + mgr._warm_pool.put_nowait((pooled, time.monotonic())) + + with monkeypatch.context() as scoped: + scoped.setattr( + mgr, + "_resolve_agent_model", + MagicMock(side_effect=RuntimeError("model resolution failed")), + ) + with pytest.raises(RuntimeError, match="model resolution failed"): + await mgr.get_or_create("test-key", agent="kirocrew", model="custom-model") + + assert mgr._pool.state.claimed_spawn_times == {} + factory.assert_not_called() + @pytest.mark.asyncio async def test_claim_forwards_canonical_crew_identity_to_rekey(self): """The claiming session's crew_agent kwarg reaches rekey so the pooled diff --git a/test/test_session_pool_explicit_default.py b/test/test_session_pool_explicit_default.py new file mode 100644 index 00000000000..e37d6a4f672 --- /dev/null +++ b/test/test_session_pool_explicit_default.py @@ -0,0 +1,1164 @@ +"""Tests for how a pending explicit effort Default reaches a session's cold start. + +A Default picked for a chat stays durable in the session map until a cold start applies +its ``cli.json`` projection: the allocation skips the warm pool for it, reserves the key +from the basis read through registration, waits for a pick's in-flight save, and clears +the flag only once the projection ran. The warm-pool fence those starts rewrite inside +lives in ``test_session_pool.py``; the shared fixture and stand-ins in +``session_pool_helpers.py``. +""" + +from __future__ import annotations + +import asyncio +import time +from contextlib import nullcontext +from unittest.mock import AsyncMock, MagicMock, call + +import pytest +from session_pool_helpers import ( # noqa: F401 -- the autouse fixture registers by import + _isolate_config_dir, + _make_manager, + _make_provider, +) + +from kiro_crew.testing.ids import UNALLOCATABLE_PID +from kiro_crew.testing.wait import async_wait_until, default_timeout + + +async def _await_test(awaitable, what: str): + try: + async with asyncio.timeout(default_timeout()) as deadline: + return await awaitable + except TimeoutError as exc: + if not deadline.expired(): + raise + raise AssertionError(f"timed out waiting for {what}") from exc + + +class TestExplicitEffortDefaultAllocation: + """A pending explicit Default stays durable until a start applies its projection.""" + + KEY = "test-key" + + @staticmethod + def _cold_manager(applied: bool, start: AsyncMock | None = None): + mgr, factory = _make_manager() + mgr._drain_and_claim = AsyncMock(return_value=_make_provider()) + mgr._schedule_replenish = MagicMock() + mgr._record_pool_decision = MagicMock() + provider = _make_provider() + provider.explicit_effort_default_applied = applied + if start is not None: + provider.start = start + factory.side_effect = None + factory.return_value = provider + return mgr, factory, provider + + @pytest.mark.asyncio + async def test_a_reservation_loser_waits_for_the_default_start_to_register(self): + reserver_started = asyncio.Event() + release_reserver = asyncio.Event() + contender_checked_reservation = asyncio.Event() + + async def reserver_start() -> None: + reserver_started.set() + await _await_test(release_reserver.wait(), "reserver release") + + mgr, factory, reserver = self._cold_manager( + applied=True, start=AsyncMock(side_effect=reserver_start) + ) + contender = _make_provider() + contender.explicit_effort_default_applied = False + factory.side_effect = [reserver, contender] + service = mgr._allocation_boundary() + real_reserve = service._reserve_explicit_effort_default + reserve_calls = 0 + + def reserve(key: str, *args): + nonlocal reserve_calls + reserve_calls += 1 + result = real_reserve(key, *args) + if reserve_calls == 2: + contender_checked_reservation.set() + return result + + service._reserve_explicit_effort_default = reserve + mgr.set_explicit_effort_default(self.KEY, True) + reserving = asyncio.create_task(mgr.get_or_create(self.KEY, agent="kirocrew")) + contender_task: asyncio.Task | None = None + try: + await _await_test(reserver_started.wait(), "reserving start") + contender_task = asyncio.create_task(mgr.get_or_create(self.KEY, agent="kirocrew")) + await _await_test(contender_checked_reservation.wait(), "contender reservation check") + + assert contender.start.await_count == 0 + release_reserver.set() + registered, _is_new, _resumed = await _await_test(reserving, "reserving allocation") + assert registered is reserver + mgr.release(self.KEY) + reused, _is_new, _resumed = await _await_test(contender_task, "waiting allocation") + + assert reused is reserver + assert factory.call_count == 1 + contender.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(self.KEY) is False + finally: + release_reserver.set() + participants = [reserving] + if contender_task is not None: + participants.append(contender_task) + for participant in participants: + if not participant.done(): + participant.cancel() + await _await_test( + asyncio.gather(*participants, return_exceptions=True), + "default allocation race cleanup", + ) + + @pytest.mark.asyncio + async def test_a_waiter_reserves_and_arms_after_the_reserving_start_fails(self): + reserver_started = asyncio.Event() + release_reserver = asyncio.Event() + contender_checked_reservation = asyncio.Event() + + async def failing_start() -> None: + reserver_started.set() + await _await_test(release_reserver.wait(), "reserver release") + raise RuntimeError("spawn failed") + + mgr, factory, reserver = self._cold_manager( + applied=False, start=AsyncMock(side_effect=failing_start) + ) + contender = _make_provider() + contender.explicit_effort_default_applied = True + factory.side_effect = [reserver, contender] + service = mgr._allocation_boundary() + real_reserve = service._reserve_explicit_effort_default + reserve_calls = 0 + + def reserve(key: str, *args): + nonlocal reserve_calls + reserve_calls += 1 + result = real_reserve(key, *args) + if reserve_calls == 2: + contender_checked_reservation.set() + return result + + service._reserve_explicit_effort_default = reserve + mgr.set_explicit_effort_default(self.KEY, True) + reserving = asyncio.create_task(mgr.get_or_create(self.KEY, agent="kirocrew")) + contender_task: asyncio.Task | None = None + try: + await _await_test(reserver_started.wait(), "reserving start") + contender_task = asyncio.create_task(mgr.get_or_create(self.KEY, agent="kirocrew")) + await _await_test(contender_checked_reservation.wait(), "contender reservation check") + + assert contender.start.await_count == 0 + release_reserver.set() + with pytest.raises(RuntimeError, match="spawn failed"): + await _await_test(reserving, "failing allocation") + created, _is_new, _resumed = await _await_test(contender_task, "replacement allocation") + + assert created is contender + contender.arm_explicit_effort_default.assert_called_once_with( + mgr.fence_effort_overlay_rewrite + ) + assert factory.call_count == 2 + assert mgr.explicit_effort_default_pending(self.KEY) is False + finally: + release_reserver.set() + participants = [reserving] + if contender_task is not None: + participants.append(contender_task) + for participant in participants: + if not participant.done(): + participant.cancel() + await _await_test( + asyncio.gather(*participants, return_exceptions=True), + "failed default allocation race cleanup", + ) + + @pytest.mark.asyncio + async def test_cancelling_a_default_reservation_waiter_leaks_no_state(self): + reserver_started = asyncio.Event() + release_reserver = asyncio.Event() + contender_checked_reservation = asyncio.Event() + + async def reserver_start() -> None: + reserver_started.set() + await _await_test(release_reserver.wait(), "reserver release") + + mgr, factory, reserver = self._cold_manager( + applied=True, start=AsyncMock(side_effect=reserver_start) + ) + contender = _make_provider() + factory.side_effect = [reserver, contender] + service = mgr._allocation_boundary() + real_reserve = service._reserve_explicit_effort_default + reserve_calls = 0 + + def reserve(key: str, *args): + nonlocal reserve_calls + reserve_calls += 1 + result = real_reserve(key, *args) + if reserve_calls == 2: + contender_checked_reservation.set() + return result + + async def await_waiter_released_allocation() -> None: + while len(service._allocation_reservations.get(self.KEY, ())) != 1: + await asyncio.sleep(0) + + service._reserve_explicit_effort_default = reserve + mgr.set_explicit_effort_default(self.KEY, True) + reserving = asyncio.create_task(mgr.get_or_create(self.KEY, agent="kirocrew")) + contender_task: asyncio.Task | None = None + try: + await _await_test(reserver_started.wait(), "reserving start") + contender_task = asyncio.create_task(mgr.get_or_create(self.KEY, agent="kirocrew")) + await _await_test(contender_checked_reservation.wait(), "contender reservation check") + await _await_test(await_waiter_released_allocation(), "waiter allocation release") + + contender_task.cancel() + with pytest.raises(asyncio.CancelledError): + await _await_test(contender_task, "cancelled waiter") + assert contender.start.await_count == 0 + assert len(service.state.explicit_effort_default_reservations) == 1 + assert len(service._allocation_reservations[self.KEY]) == 1 + + release_reserver.set() + created, _is_new, _resumed = await _await_test(reserving, "reserving allocation") + assert created is reserver + assert service.state.explicit_effort_default_reservations == {} + assert service._allocation_reservations == {} + finally: + release_reserver.set() + participants = [reserving] + if contender_task is not None: + participants.append(contender_task) + for participant in participants: + if not participant.done(): + participant.cancel() + await _await_test( + asyncio.gather(*participants, return_exceptions=True), + "cancelled default allocation race cleanup", + ) + + @pytest.mark.asyncio + async def test_a_pending_default_skips_the_pool_and_arms_the_provider(self): + mgr, _factory, provider = self._cold_manager(applied=True) + mgr.set_explicit_effort_default(self.KEY, True) + + created, _is_new, _ = await mgr.get_or_create(self.KEY, agent="kirocrew") + + # A warm process read cli.json at its own spawn and Default cannot be + # pushed live, so only a cold start can apply the intent. + assert created is provider + mgr._drain_and_claim.assert_not_awaited() + mgr._record_pool_decision.assert_called_once_with("bypass_effort", self.KEY) + provider.arm_explicit_effort_default.assert_called_once_with( + mgr.fence_effort_overlay_rewrite + ) + assert mgr.explicit_effort_default_pending(self.KEY) is False + + @pytest.mark.asyncio + @pytest.mark.parametrize("applied", [True, False]) + async def test_the_key_is_reserved_from_the_basis_read_through_registration(self, applied): + # The span the effort handler's refusal rests on: the allocation + # reservation is held before the start reads the one-shot Default and + # is still held when the session is registered, so no pick can change + # the basis a start publishes on. ``effort_basis_locked`` is that + # reservation, read as the handler reads it. + mgr, _factory, provider = self._cold_manager(applied=applied) + mgr.set_explicit_effort_default(self.KEY, True) + seen: list[tuple[str, bool]] = [] + real_pending = mgr.explicit_effort_default_pending + + def pending(key: str) -> bool: + seen.append(("read", mgr.effort_basis_locked(key))) + return real_pending(key) + + async def start() -> None: + seen.append(("start", mgr.effort_basis_locked(self.KEY))) + + class Registry(dict[str, object]): + def __setitem__(self, key: str, session: object) -> None: + seen.append(("register", mgr.effort_basis_locked(key))) + super().__setitem__(key, session) + + mgr.explicit_effort_default_pending = pending + provider.start = AsyncMock(side_effect=start) + mgr._allocation_boundary()._sessions = Registry() + assert mgr.effort_basis_locked(self.KEY) is False + + created, _is_new, _ = await mgr.get_or_create(self.KEY, agent="kirocrew") + + assert created is provider + assert seen[0] == ("read", True) + assert ("start", True) in seen + assert seen[-1] == ("register", True) + assert all(locked for _step, locked in seen) + # Released once the allocation returned: the next pick lands. + assert mgr.effort_basis_locked(self.KEY) is False + + @pytest.mark.asyncio + async def test_a_claim_waiting_on_the_live_session_leaves_the_basis_unlocked(self): + # A second caller queued for the live session's lease holds the key's + # reservation for as long as the running turn lasts, but it starts + # nothing and reads no effort basis, so a Default pick made meanwhile + # is not refused as if a session were starting. + mgr, _factory, _provider = self._cold_manager(applied=False) + created, _is_new, _ = await mgr.get_or_create(self.KEY, agent="kirocrew") + + waiter = asyncio.create_task(mgr.get_or_create(self.KEY, agent="kirocrew")) + try: + await async_wait_until( + lambda: mgr._has_allocation_reservation(self.KEY), + describe=lambda: "the second claimant never reserved the key", + ) + assert mgr.effort_basis_locked(self.KEY) is False + finally: + mgr.release(self.KEY) + reused, _is_new, _ = await _await_test(waiter, "the queued claim") + assert reused is created + mgr.release(self.KEY) + assert mgr.effort_basis_locked(self.KEY) is False + + # -- a pick saving its flag when the start begins settles before the read -- + # + # The handler's check passes before the start takes the key's reservation, + # so the pick's in-memory write is already there when the start begins and + # its save is still in flight. The start waits for that write to settle + # before its single basis read, so it reads the saved value or the one a + # failed save put back, never an in-memory value no save keeps. + + async def _a_default_pick_saving_as_the_start_begins( + self, + mgr, + outcome: str, + pick_key: str | None = None, + start_key: str | None = None, + alias_folds: list[tuple[str, str]] | None = None, + ): + """Race a Default pick's save against a key's cold start. + + The pick is the effort handler's write as ``record_default_intent`` + makes it: the ``effort_basis_locked`` check passes (no reservation yet), + the flag is written in memory under the key's intent-write count, and + the save runs until the start has reached its basis read. ``outcome`` + is what the save then does: "saved", "raised" or "cancelled". Returns + the pick's task and the start's reads as ``(value, save_settled)``. + """ + pick_key = self.KEY if pick_key is None else pick_key + start_key = self.KEY if start_key is None else start_key + save_started = asyncio.Event() + save_release = asyncio.Event() + start_at_read = asyncio.Event() + save_settled = False + flushes = 0 + + async def aflush() -> None: + nonlocal flushes, save_settled + flushes += 1 + if flushes > 1: + # The pick's put-back save and the start's post-application clear save. + return + save_started.set() + await _await_test(save_release.wait(), "save_release") + save_settled = True + if outcome == "raised": + raise OSError("disk full") + if outcome == "cancelled": + raise asyncio.CancelledError() + + real_wait = mgr.wait_for_effort_intent_writes + real_pending = mgr.explicit_effort_default_pending + reads: list[tuple[bool, bool]] = [] + + async def wait_for_writes(key: str) -> None: + # The start announces it is at its read; the wait right before it + # is where it stops while the save is in flight. + if alias_folds is not None: + alias_folds.append((mgr._fold_key(pick_key), mgr._fold_key(start_key))) + start_at_read.set() + await real_wait(key) + + def pending(key: str) -> bool: + # Announced here too, for a start that reaches the read without + # waiting: the save is then released only after the read landed. + start_at_read.set() + value = real_pending(key) + reads.append((value, save_settled)) + return value + + async def pick() -> bool: + assert mgr.effort_basis_locked(pick_key) is False + with mgr.effort_intent_write(pick_key): + prior = real_pending(pick_key) + assert mgr.set_explicit_effort_default(pick_key, True) is True + try: + await mgr.aflush() + except asyncio.CancelledError: + mgr.set_explicit_effort_default(pick_key, prior) + await mgr.aflush() + raise + except Exception: + mgr.set_explicit_effort_default(pick_key, prior) + return False + return True + + mgr.aflush = aflush + mgr.wait_for_effort_intent_writes = wait_for_writes + mgr.explicit_effort_default_pending = pending + alias_seed = None + if alias_folds is not None: + alias_seed = object() + mgr._allocation_boundary()._sessions[start_key] = alias_seed + pick_task = asyncio.create_task(pick()) + start_task: asyncio.Task | None = None + try: + await _await_test(save_started.wait(), "default save starting") + if alias_seed is not None: + assert mgr._allocation_boundary()._sessions.pop(start_key) is alias_seed + start_task = asyncio.create_task(mgr.get_or_create(start_key, agent="kirocrew")) + await _await_test(start_at_read.wait(), "cold start reaching its basis read") + save_release.set() + created, _is_new, _ = await _await_test(start_task, "cold start completion") + except BaseException: + save_release.set() + participants = [pick_task] + if start_task is not None: + participants.append(start_task) + for participant in participants: + if not participant.done(): + participant.cancel() + await _await_test( + asyncio.gather(*participants, return_exceptions=True), + "allocation-race cleanup", + ) + raise + return pick_task, created, reads + + @pytest.mark.asyncio + async def test_a_default_whose_save_raises_under_a_cold_start_is_not_read_by_it(self): + mgr, _factory, provider = self._cold_manager(applied=True) + + pick_task, created, reads = await self._a_default_pick_saving_as_the_start_begins( + mgr, "raised" + ) + + # The pick is refused (the handler answers 503) and put the flag back; + # the start read that restored value, after the save had settled, so it + # took the warm pool as a start with no Default pending does and armed + # nothing. + assert await _await_test(pick_task, "pick_task") is False + assert reads[0] == (False, True) + assert created is not provider + mgr._drain_and_claim.assert_awaited_once() + provider.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert self.KEY not in mgr._effort_intent_writes + assert mgr.effort_basis_locked(self.KEY) is False + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("pick_key", "start_key"), + [ + ("slack:1700000000.000100", "1700000000.000100"), + ("1700000000.000100", "slack:1700000000.000100"), + ], + ) + async def test_a_start_under_an_alias_waits_for_a_failed_default_save( + self, pick_key, start_key + ): + mgr, _factory, provider = self._cold_manager(applied=True) + alias_folds: list[tuple[str, str]] = [] + + pick_task, created, reads = await self._a_default_pick_saving_as_the_start_begins( + mgr, + "raised", + pick_key=pick_key, + start_key=start_key, + alias_folds=alias_folds, + ) + + assert alias_folds[0][0] == alias_folds[0][1] + assert await _await_test(pick_task, "pick_task") is False + assert reads[0] == (False, True) + assert created is not provider + mgr._drain_and_claim.assert_awaited_once() + provider.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(start_key) is False + assert "slack:1700000000.000100" not in mgr._effort_intent_writes + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("pick_key", "start_key"), + [ + ("slack:1700000000.000100", "1700000000.000100"), + ("1700000000.000100", "slack:1700000000.000100"), + ], + ) + async def test_a_cold_start_under_an_alias_waits_for_a_failed_default_save( + self, pick_key, start_key + ): + mgr, _factory, provider = self._cold_manager(applied=True) + + pick_task, created, reads = await self._a_default_pick_saving_as_the_start_begins( + mgr, + "raised", + pick_key=pick_key, + start_key=start_key, + ) + + assert await _await_test(pick_task, "pick_task") is False + assert reads[0] == (False, True) + assert created is not provider + mgr._drain_and_claim.assert_awaited_once() + provider.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(start_key) is False + + @pytest.mark.asyncio + async def test_a_default_whose_save_lands_under_a_cold_start_is_applied_and_cleared(self): + mgr, _factory, provider = self._cold_manager(applied=True) + + pick_task, created, reads = await self._a_default_pick_saving_as_the_start_begins( + mgr, "saved" + ) + + # The start read the saved value once the save had settled, skipped the + # pool for it, applied it, and cleared the durable flag afterward. + assert await _await_test(pick_task, "pick_task") is True + assert reads[0] == (True, True) + assert created is provider + mgr._drain_and_claim.assert_not_awaited() + provider.arm_explicit_effort_default.assert_called_once_with( + mgr.fence_effort_overlay_rewrite + ) + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert self.KEY not in mgr._effort_intent_writes + + @pytest.mark.asyncio + async def test_a_default_whose_save_is_cancelled_under_a_cold_start_is_not_read_by_it(self): + mgr, _factory, provider = self._cold_manager(applied=True) + + pick_task, created, reads = await self._a_default_pick_saving_as_the_start_begins( + mgr, "cancelled" + ) + + with pytest.raises(asyncio.CancelledError): + await _await_test(pick_task, "pick_task") + assert reads[0] == (False, True) + assert created is not provider + mgr._drain_and_claim.assert_awaited_once() + provider.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert self.KEY not in mgr._effort_intent_writes + + @pytest.mark.asyncio + async def test_the_keys_write_count_is_gone_once_its_last_write_has_exited(self): + # Nothing outlives a write: the entry is removed when the count reaches + # zero, however the write ended, and a waiter wakes at that moment. + mgr, _factory, _provider = self._cold_manager(applied=True) + + with mgr.effort_intent_write(self.KEY): + assert mgr._effort_intent_writes[self.KEY].count == 1 + assert self.KEY not in mgr._effort_intent_writes + + with pytest.raises(OSError): + with mgr.effort_intent_write(self.KEY): + raise OSError("disk full") + assert self.KEY not in mgr._effort_intent_writes + + with pytest.raises(asyncio.CancelledError): + with mgr.effort_intent_write(self.KEY): + raise asyncio.CancelledError() + assert self.KEY not in mgr._effort_intent_writes + + # Two picks under one key: the entry stays until the last one exits, + # and a waiter is released only then. + waiting = asyncio.Event() + + async def waiter() -> None: + waiting.set() + await mgr.wait_for_effort_intent_writes(self.KEY) + + with mgr.effort_intent_write(self.KEY): + with mgr.effort_intent_write(self.KEY): + entry = mgr._effort_intent_writes[self.KEY] + assert entry.count == 2 + waiter_task = asyncio.create_task(waiter()) + await _await_test(waiting.wait(), "waiting") + assert not waiter_task.done() + assert entry.count == 1 + assert not entry.settled.is_set() + assert not waiter_task.done() + assert entry.settled.is_set() + await _await_test(waiter_task, "waiter_task") + assert mgr._effort_intent_writes == {} + + @pytest.mark.asyncio + async def test_with_no_write_in_flight_the_basis_read_is_not_delayed(self): + mgr, _factory, provider = self._cold_manager(applied=True) + mgr.set_explicit_effort_default(self.KEY, True) + assert getattr(mgr, "_effort_intent_writes", {}) == {} + + # The wait completes on its first step: nothing to wait for, no + # suspension, and no write was ever entered. + wait = mgr.wait_for_effort_intent_writes(self.KEY) + with pytest.raises(StopIteration): + wait.send(None) + + created, _is_new, _ = await mgr.get_or_create(self.KEY, agent="kirocrew") + + assert created is provider + provider.arm_explicit_effort_default.assert_called_once_with( + mgr.fence_effort_overlay_rewrite + ) + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert getattr(mgr, "_effort_intent_writes", {}) == {} + + @pytest.mark.asyncio + async def test_a_start_that_did_not_apply_it_keeps_the_intent(self): + mgr, _factory, _provider = self._cold_manager(applied=False) + mgr.set_explicit_effort_default(self.KEY, True) + + await mgr.get_or_create(self.KEY, agent="kirocrew") + + # The projection could not rewrite the file (locked, a link, past its + # ceiling), so the next cold start tries again. + assert mgr.explicit_effort_default_pending(self.KEY) is True + + @pytest.mark.asyncio + async def test_the_start_rewrites_inside_the_warm_pools_fence(self): + inside: list[int] = [] + + async def start() -> None: + (fence,) = provider.arm_explicit_effort_default.call_args.args + with fence(): + inside.append(mgr._pool.state.effort_overlay_rewrites) + + mgr, _factory, provider = self._cold_manager( + applied=True, start=AsyncMock(side_effect=start) + ) + mgr.set_explicit_effort_default(self.KEY, True) + before = time.monotonic() + + await mgr.get_or_create(self.KEY, agent="kirocrew") + + # The fence the start rewrites inside is the pool's: claims are refused + # while it is up, and runtimes queued before it came down are discarded. + assert inside == [1] + assert mgr._pool.state.effort_overlay_rewrites == 0 + assert mgr._pool.state.effort_overlay_epoch >= before + + @pytest.mark.asyncio + @pytest.mark.parametrize("error", [RuntimeError("spawn failed"), asyncio.CancelledError()]) + @pytest.mark.parametrize("applied", [True, False]) + async def test_a_start_that_fails_arms_it_again_only_when_it_did_not_apply( + self, applied, error + ): + mgr, _factory, _provider = self._cold_manager( + applied=applied, start=AsyncMock(side_effect=error) + ) + mgr.set_explicit_effort_default(self.KEY, True) + + with pytest.raises(type(error)): + await mgr.get_or_create(self.KEY, agent="kirocrew") + + # A projection that ran is cleared in memory for the deferred save. A + # failure before projection leaves the durable intent untouched. + assert mgr.explicit_effort_default_pending(self.KEY) is (not applied) + assert mgr._allocation_state.explicit_effort_default_reservations == {} + + @pytest.mark.asyncio + @pytest.mark.parametrize("start_fails", [False, True]) + async def test_a_default_revoked_before_provider_arming_stays_revoked( + self, monkeypatch, start_fails + ): + from kiro_crew import session_allocation + + async def pre_spawn_identity(*_args, **_kwargs): + # The reservation serializes competing starts but does not authorize + # an overlay rewrite after the durable intent has been revoked. + assert mgr.set_explicit_effort_default(self.KEY, False) is True + return "" + + monkeypatch.setattr(session_allocation, "pre_spawn_identity", pre_spawn_identity) + start = AsyncMock(side_effect=RuntimeError("spawn failed")) if start_fails else None + mgr, _factory, provider = self._cold_manager(applied=False, start=start) + assert mgr.set_explicit_effort_default(self.KEY, True) is True + + if start_fails: + with pytest.raises(RuntimeError, match="spawn failed"): + await mgr.get_or_create(self.KEY, agent="kirocrew") + else: + await mgr.get_or_create(self.KEY, agent="kirocrew") + + # The pending Default was the authorization to remove an entry Kiro + # Crew did not write. Once another allocation clears it, this start arms + # no replacement and a later failure does not restore that old intent. + provider.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(self.KEY) is False + + @pytest.mark.asyncio + async def test_the_flag_stays_on_disk_until_the_projection_applies(self, tmp_path): + from kiro_crew.session_map import SessionMap + + work_dir = tmp_path / "work" + settings = work_dir / ".kiro" / "settings" + settings.mkdir(parents=True) + cli_json = settings / "cli.json" + cli_json.write_text( + '{"chat":{"modelDefaults":{"gpt-5.6":{"reasoning":{"effort":"max"}}}}}', + encoding="utf-8", + ) + start_entered = asyncio.Event() + apply_projection = asyncio.Event() + + async def start() -> None: + start_entered.set() + await _await_test(apply_projection.wait(), "projection release") + cli_json.write_text('{"chat":{"modelDefaults":{}}}', encoding="utf-8") + provider.explicit_effort_default_applied = True + + mgr, factory = _make_manager() + provider = _make_provider() + provider.explicit_effort_default_applied = False + provider.start = AsyncMock(side_effect=start) + factory.side_effect = None + factory.return_value = provider + mgr._drain_and_claim = AsyncMock(return_value=None) + mgr._schedule_replenish = MagicMock() + mgr._record_pool_decision = MagicMock() + mgr.set_explicit_effort_default(self.KEY, True) + await mgr.aflush() + + allocation = asyncio.create_task( + mgr.get_or_create(self.KEY, agent="kirocrew", cwd=str(work_dir)) + ) + await _await_test(start_entered.wait(), "provider start") + + # This fresh reader sees the file, not the manager's in-memory map. A + # kill here must leave the next process enough intent to replay Default. + assert SessionMap().get_flag(self.KEY, "explicit_effort_default") is True + assert '"effort":"max"' in cli_json.read_text(encoding="utf-8") + + apply_projection.set() + created, _is_new, _ = await _await_test(allocation, "allocation") + assert created is provider + assert SessionMap().get_flag(self.KEY, "explicit_effort_default") is False + assert '"effort":"max"' not in cli_json.read_text(encoding="utf-8") + + @pytest.mark.asyncio + async def test_a_kill_before_projection_leaves_the_next_manager_armed(self): + from kiro_crew.session_map import SessionMap + + mgr, _factory, _provider = self._cold_manager(applied=False) + mgr.set_explicit_effort_default(self.KEY, True) + await mgr.aflush() + allocation = mgr._allocation_boundary() + with mgr.hold_explicit_effort_default(self.KEY): + assert allocation._reserve_explicit_effort_default(self.KEY) is True + + # Simulate process death: no provider exception arm and no manager cleanup. + del allocation + del mgr + assert SessionMap().get_flag(self.KEY, "explicit_effort_default") is True + + restarted, _factory, provider = self._cold_manager(applied=True) + created, _is_new, _ = await restarted.get_or_create(self.KEY, agent="kirocrew") + + assert created is provider + provider.arm_explicit_effort_default.assert_called_once_with( + restarted.fence_effort_overlay_rewrite + ) + assert restarted.explicit_effort_default_pending(self.KEY) is False + + @pytest.mark.asyncio + async def test_a_cancelled_clear_keeps_the_applied_default_cleared_in_memory(self): + mgr, _factory, provider = self._cold_manager(applied=True) + mgr.set_explicit_effort_default(self.KEY, True) + mgr.aflush = AsyncMock(side_effect=asyncio.CancelledError()) + + with pytest.raises(asyncio.CancelledError): + await mgr.get_or_create(self.KEY, agent="kirocrew") + + provider.start.assert_awaited_once() + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert mgr._allocation_state.explicit_effort_default_reservations == {} + + @pytest.mark.asyncio + async def test_the_clear_is_saved_only_after_the_projection_applies(self): + mgr, _factory, provider = self._cold_manager(applied=True) + mgr.set_explicit_effort_default(self.KEY, True) + seen: list[tuple[bool, int, int]] = [] + + async def aflush(): + seen.append( + ( + mgr.explicit_effort_default_pending(self.KEY), + provider.arm_explicit_effort_default.call_count, + provider.start.await_count, + ) + ) + + mgr.aflush = aflush + + await mgr.get_or_create(self.KEY, agent="kirocrew") + + assert seen == [(False, 1, 1)] + provider.arm_explicit_effort_default.assert_called_once_with( + mgr.fence_effort_overlay_rewrite + ) + assert mgr.explicit_effort_default_pending(self.KEY) is False + + @pytest.mark.asyncio + async def test_the_started_pid_is_shielded_while_the_clear_is_saved(self): + mgr, _factory, provider = self._cold_manager(applied=True) + provider.pid = UNALLOCATABLE_PID + mgr.set_explicit_effort_default(self.KEY, True) + shielded_during_save: list[bool] = [] + + async def aflush(): + shielded_during_save.append(UNALLOCATABLE_PID in mgr._starting_pids) + + mgr.aflush = aflush + + await mgr.get_or_create(self.KEY, agent="kirocrew") + + assert shielded_during_save == [True] + assert UNALLOCATABLE_PID not in mgr._starting_pids + + @pytest.mark.asyncio + async def test_a_start_cancelled_during_the_clear_save_drops_its_pid_shield(self): + mgr, _factory, provider = self._cold_manager(applied=True) + provider.pid = UNALLOCATABLE_PID + mgr.set_explicit_effort_default(self.KEY, True) + mgr.aflush = AsyncMock(side_effect=asyncio.CancelledError()) + + with pytest.raises(asyncio.CancelledError): + await mgr.get_or_create(self.KEY, agent="kirocrew") + + assert UNALLOCATABLE_PID not in mgr._starting_pids + + @pytest.mark.asyncio + async def test_a_clear_save_failure_does_not_fail_the_start(self, caplog): + mgr, _factory, provider = self._cold_manager(applied=True) + mgr.set_explicit_effort_default(self.KEY, True) + real_aflush = mgr.aflush + attempts = 0 + + async def fail_once() -> None: + nonlocal attempts + attempts += 1 + if attempts == 1: + raise OSError("disk full") + await real_aflush() + + mgr.aflush = fail_once + + created, _is_new, _ = await mgr.get_or_create(self.KEY, agent="kirocrew") + + assert created is provider + assert self.KEY in mgr._sessions + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert "the Default already ran" in caplog.text + + await mgr.aflush() + from kiro_crew.session_map import SessionMap + + assert SessionMap().get_flag(self.KEY, "explicit_effort_default") is False + + @pytest.mark.asyncio + async def test_two_allocations_of_one_canonical_key_only_one_arms(self): + mgr, _factory, _provider = self._cold_manager(applied=False) + service = mgr._allocation_boundary() + slack_key = "slack:1700000000.000100" + bare_key = "1700000000.000100" + assert mgr.set_explicit_effort_default(slack_key, True) is True + providers = [_make_provider(), _make_provider()] + first_reserved = asyncio.Event() + release_first = asyncio.Event() + + async def reserve(key: str, provider, wait: bool) -> bool: + with mgr.hold_explicit_effort_default(key): + reserved = service._reserve_explicit_effort_default(key) + if reserved: + provider.arm_explicit_effort_default(mgr.fence_effort_overlay_rewrite) + if wait: + first_reserved.set() + await _await_test(release_first.wait(), "first reservation release") + if reserved: + service._release_explicit_effort_default(key) + return reserved + + first = asyncio.create_task(reserve(slack_key, providers[0], True)) + try: + await _await_test(first_reserved.wait(), "first reservation") + second = asyncio.create_task(reserve(bare_key, providers[1], False)) + assert await _await_test(second, "second reservation") is False + finally: + release_first.set() + assert await _await_test(first, "first reservation completion") is True + + assert providers[0].arm_explicit_effort_default.call_count == 1 + providers[1].arm_explicit_effort_default.assert_not_called() + assert service.state.explicit_effort_default_reservations == {} + + @pytest.mark.asyncio + @pytest.mark.parametrize("path", ["unapplied", "raised"]) + async def test_another_chat_cannot_take_the_row_while_the_flag_is_armed( + self, monkeypatch, path + ): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + other = "dashboard:another-chat" + admitted = [] + + def another_chat_picks_default(): + # Another chat's Default pick while this key's flag keeps the row counted. + if not admitted: + admitted.append(mgr.set_explicit_effort_default(other, True)) + + async def start(): + another_chat_picks_default() + if path == "raised": + raise RuntimeError("spawn failed") + + mgr, _factory, _provider = self._cold_manager( + applied=False, start=AsyncMock(side_effect=start) + ) + assert mgr.set_explicit_effort_default(self.KEY, True) is True + + if path == "raised": + with pytest.raises(RuntimeError, match="spawn failed"): + await mgr.get_or_create(self.KEY, agent="kirocrew") + else: + await mgr.get_or_create(self.KEY, agent="kirocrew") + + # The armed flag keeps this key's row counted, so the other pick is + # refused and the row bound remains intact. + assert admitted == [False] + assert mgr.explicit_effort_default_pending(self.KEY) is True + assert mgr.explicit_effort_default_pending(other) is False + + @pytest.mark.asyncio + async def test_the_row_is_free_again_once_an_applied_start_clears_the_flag(self, monkeypatch): + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + other = "dashboard:another-chat" + during_start = [] + + async def start(): + during_start.append(mgr.set_explicit_effort_default(other, True)) + + mgr, _factory, _provider = self._cold_manager( + applied=True, start=AsyncMock(side_effect=start) + ) + assert mgr.set_explicit_effort_default(self.KEY, True) is True + + await mgr.get_or_create(self.KEY, agent="kirocrew") + + # Refused while the flag keeps this key's row counted; free once the + # replacement runs and clears it. + assert during_start == [False] + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert mgr.set_explicit_effort_default(other, True) is True + + @pytest.mark.asyncio + async def test_the_applied_clear_frees_the_row_before_the_session_is_registered( + self, monkeypatch + ): + from kiro_crew import session_allocation + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + other = "dashboard:another-chat" + after_start = [] + + async def stamp_spawn_identity(*_args, **_kwargs): + after_start.append(mgr.set_explicit_effort_default(other, True)) + + monkeypatch.setattr(session_allocation, "stamp_spawn_identity", stamp_spawn_identity) + mgr, _factory, _provider = self._cold_manager(applied=True) + assert mgr.set_explicit_effort_default(self.KEY, True) is True + + await mgr.get_or_create(self.KEY, agent="kirocrew") + + # The applied clear frees the row before the allocation registers the session. + assert after_start == [True] + + @pytest.mark.asyncio + async def test_a_level_start_leaves_the_row_to_the_default_it_does_not_apply(self, monkeypatch): + # A level start does not clear a Default it does not apply, so it never + # writes the flag: the pending flag itself keeps the row counted, and + # another chat's pick is refused during the start and after it. + from kiro_crew import session_map as session_map_module + + monkeypatch.setattr(session_map_module, "EXPLICIT_EFFORT_DEFAULT_ROW_CAP", 1) + other = "dashboard:another-chat" + during_start = [] + writes_of_this_key = [] + + async def start(): + during_start.append(mgr.set_explicit_effort_default(other, True)) + + mgr, _factory, _provider = self._cold_manager( + applied=True, start=AsyncMock(side_effect=start) + ) + mgr._drain_and_claim = AsyncMock(return_value=None) + assert mgr.set_explicit_effort_default(self.KEY, True) is True + real_set_explicit_effort_default = mgr.set_explicit_effort_default + + def record_writes(key: str, pending: bool) -> bool: + if mgr._fold_key(key) == mgr._fold_key(self.KEY): + writes_of_this_key.append(pending) + return real_set_explicit_effort_default(key, pending) + + mgr.set_explicit_effort_default = record_writes + + await mgr.get_or_create(self.KEY, agent="kirocrew", reasoning_effort_override="high") + + assert writes_of_this_key == [] + assert during_start == [False] + assert mgr.explicit_effort_default_pending(self.KEY) is True + assert mgr.explicit_effort_default_pending(other) is False + assert mgr.set_explicit_effort_default(other, True) is False + + @pytest.mark.asyncio + async def test_a_level_the_caller_starts_on_leaves_a_pending_default_in_place(self): + # A start that does not apply the Default must not clear it: the level + # the caller chose is not a newer effort action on the key (a level pick + # through the effort handler, or a level session_set_model commits at + # the turn start, clears the flag itself), so the Default is left for + # the next start that takes no override. + mgr, _factory, provider = self._cold_manager(applied=True) + mgr._drain_and_claim = AsyncMock(return_value=None) + mgr.set_explicit_effort_default(self.KEY, True) + + await mgr.get_or_create(self.KEY, agent="kirocrew", reasoning_effort_override="high") + + # A stale Default never authorizes replacing an entry for a level start, + # and the level start leaves it pending. + provider.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(self.KEY) is True + + @pytest.mark.asyncio + async def test_a_committed_session_set_model_level_spends_the_pending_default( + self, tmp_path, monkeypatch + ): + from chat_test_helpers import _make_state + + from kiro_crew.dashboard import session_control as session_control_module + from kiro_crew.dashboard.chat_utils import effective_session_key, slot_history_key + + mgr, _factory, provider = self._cold_manager(applied=True) + mgr._drain_and_claim = AsyncMock(return_value=None) + state = _make_state(tmp_path) + caller = state.get_or_create_slot("chat-1") + target = state.get_or_create_slot("chat-2") + state.sessions = mgr + monkeypatch.setattr(session_control_module, "session_control_enabled", lambda: True) + key = effective_session_key(target) + mgr.set_explicit_effort_default(key, True) + settings = tmp_path / ".kiro" / "settings" / "cli.json" + settings.parent.mkdir(parents=True) + original = b'{"chat":{"modelDefaults":{"sonnet":{"output_config":{"effort":"max"}}}}}' + settings.write_bytes(original) + + await session_control_module.set_model_target( + state, + caller_session_key=slot_history_key(caller), + target="chat-2", + reasoning_effort="high", + ) + assert session_control_module.apply_pending_model_pick(state, target) is True + assert mgr.explicit_effort_default_pending(key) is False + + created, _is_new, _ = await mgr.get_or_create(key, agent="kirocrew", cwd=str(tmp_path)) + + assert created is provider + provider.arm_explicit_effort_default.assert_not_called() + assert settings.read_bytes() == original + + @pytest.mark.asyncio + async def test_a_pending_default_survives_a_level_start_and_the_next_start_applies_it( + self, + ): + """A start carrying an override never clears a Default it does not apply. + + The override is a level the caller chose: a slot's level read before the + Default pick landed, an alias slot's own level, or a caller's pin. That + start runs the level, arms nothing, and leaves the flag pending; the + key's next start without an override skips the pool for it, arms the + provider and clears it after the projection. Before the fix the level + start cleared the flag at its basis read, so the next start found nothing + to apply. + """ + mgr, factory, level_provider = self._cold_manager(applied=True) + mgr._drain_and_claim = AsyncMock(return_value=None) + assert mgr.set_explicit_effort_default(self.KEY, True) is True + + created, _is_new, _ = await mgr.get_or_create( + self.KEY, agent="kirocrew", reasoning_effort_override="high" + ) + + # The level start ran the caller's level and took the pool path (a miss + # here), arming no Default and leaving the flag as it found it. + assert created is level_provider + assert factory.call_args.kwargs["reasoning_effort_override"] == "high" + level_provider.arm_explicit_effort_default.assert_not_called() + assert mgr.explicit_effort_default_pending(self.KEY) is True + assert mgr._record_pool_decision.call_args_list == [call("miss_empty", self.KEY)] + + mgr.release(self.KEY) + await mgr.reset(self.KEY) + default_provider = _make_provider() + default_provider.explicit_effort_default_applied = True + factory.return_value = default_provider + + created, _is_new, _ = await mgr.get_or_create(self.KEY, agent="kirocrew") + + # The next start without an override applies the Default and clears it. + assert created is default_provider + assert "reasoning_effort_override" not in factory.call_args.kwargs + default_provider.arm_explicit_effort_default.assert_called_once_with( + mgr.fence_effort_overlay_rewrite + ) + assert mgr.explicit_effort_default_pending(self.KEY) is False + assert mgr._record_pool_decision.call_args_list[-1] == call("bypass_effort", self.KEY) + mgr._drain_and_claim.assert_awaited_once() + + def test_a_provider_that_reads_no_overlay_has_nothing_to_arm(self): + from kiro_crew.providers.base import LLMProvider + + # Declared on the base so the session layer calls and reads them without + # a probe; a provider with no workspace overlay clears an applied Default. + assert LLMProvider.arm_explicit_effort_default(object(), nullcontext) is None + assert LLMProvider.explicit_effort_default_applied.fget(object()) is True + + @pytest.mark.asyncio + async def test_without_a_pending_default_nothing_is_armed(self): + mgr, _factory, provider = self._cold_manager(applied=False) + mgr._drain_and_claim = AsyncMock(return_value=None) + + await mgr.get_or_create(self.KEY, agent="kirocrew") + + mgr._drain_and_claim.assert_awaited_once() + provider.arm_explicit_effort_default.assert_not_called() + + @pytest.mark.asyncio + async def test_a_reset_keeps_the_intent_for_the_next_cold_start(self): + # The handler records the intent and then resets the session; the reset + # recycles the process and keeps the map entry the intent lives on. + mgr, _factory, _provider = self._cold_manager(applied=True) + await mgr.get_or_create(self.KEY, agent="kirocrew") + mgr.set_explicit_effort_default(self.KEY, True) + + await mgr.reset(self.KEY) + + assert mgr.explicit_effort_default_pending(self.KEY) is True diff --git a/test/test_session_switch_notice.py b/test/test_session_switch_notice.py index 6b6faf8430f..2f69acf2e59 100644 --- a/test/test_session_switch_notice.py +++ b/test/test_session_switch_notice.py @@ -6,6 +6,7 @@ from __future__ import annotations +import contextlib from unittest.mock import AsyncMock, MagicMock import pytest @@ -44,6 +45,12 @@ def state(tmp_path, monkeypatch): monkeypatch.setattr("kiro_crew.dashboard.state.config_dir", lambda: tmp_path) st = _make_state(tmp_path) st.sessions.reset = AsyncMock() + # No cold start in flight and no explicit Default pending, so the effort + # handler's intent write is not refused. + st.sessions.effort_basis_locked = MagicMock(return_value=False) + st.sessions.explicit_effort_default_pending = MagicMock(return_value=False) + st.sessions.set_explicit_effort_default = MagicMock(return_value=True) + st.sessions.effort_intent_write = MagicMock(side_effect=lambda _key: contextlib.nullcontext()) return st diff --git a/test/test_session_work_dir.py b/test/test_session_work_dir.py index d88153ddef2..ad56bfb8cd8 100644 --- a/test/test_session_work_dir.py +++ b/test/test_session_work_dir.py @@ -30,6 +30,7 @@ from kiro_crew import session_pid, session_work_dir from kiro_crew.acp.types import ACP_BACKEND_KIRO from kiro_crew.config.loader import KiroCrewConfig +from kiro_crew.providers.acp import _KIROCREW_EFFORT_OWNED_KEY, _write_cli_overlay from kiro_crew.testing.wait import async_wait_until from kiro_crew.workspace_cli_settings import CLI_SETTINGS_LOCK_NAME @@ -277,6 +278,16 @@ def test_a_residue_only_directory_is_removed(self, tmp_path: Path) -> None: assert session_work_dir.reclaim_session_work_dir(work_dir) is True assert not work_dir.exists() + def test_a_directory_holding_an_effort_projection_is_removed(self, tmp_path: Path) -> None: + """An effort projection and its in-file record remain ordinary cli.json residue.""" + work_dir = tmp_path / "subagent_0badc0de" + assert session_work_dir.mark_run_dir(work_dir) is True + _write_cli_overlay(work_dir, "claude-opus-4.7", "max") + cli_json = work_dir / ".kiro" / "settings" / "cli.json" + assert _KIROCREW_EFFORT_OWNED_KEY in json.loads(cli_json.read_text(encoding="utf-8")) + assert session_work_dir.reclaim_session_work_dir(work_dir) is True + assert not work_dir.exists() + def test_an_emptied_prefix_of_the_chain_is_still_removed(self, tmp_path: Path) -> None: """A run whose overlay was never written, or already cleared, is residue too.""" bare = tmp_path / "subagent_00000001" diff --git a/test/test_state_parts_facade.py b/test/test_state_parts_facade.py index 8b710744564..6cf1cc2f7bb 100644 --- a/test/test_state_parts_facade.py +++ b/test/test_state_parts_facade.py @@ -447,6 +447,7 @@ "_eager_spawn_retry_at": ("value", "0.0"), "_eager_spawn_task": ("value", "None"), "_effort_pick_gen": ("value", "0"), + "_effort_intent_owed": ("value", "False"), "_empty_episode_productive": ("value", "False"), "_empty_response_retries": ("value", "0"), "_end_wait_by": ("value", "''"), @@ -553,6 +554,7 @@ "_responsive": ("value", "None"), "_resumed_count": ("value", "0"), "_revived_by": ("value", "''"), + "_session_effort_intent_gen": ("value", "0"), "_session_not_found_retry_used": ("value", "False"), "_session_requested_model": ("value", "None"), "_side": ("value", "None"), diff --git a/test/test_switch_handlers_sibling_alias_busy.py b/test/test_switch_handlers_sibling_alias_busy.py index 29484686c39..16d0dc00112 100644 --- a/test/test_switch_handlers_sibling_alias_busy.py +++ b/test/test_switch_handlers_sibling_alias_busy.py @@ -82,6 +82,16 @@ def _mock_state(*slots: _ChatSlot) -> DashboardState: state._slots = {s.key: s for s in slots} state.sessions = MagicMock() state.sessions.reset = AsyncMock(return_value=True) + state.sessions.aflush = AsyncMock() + state.sessions.explicit_effort_default_pending = MagicMock(return_value=False) + state.sessions.set_explicit_effort_default = MagicMock(return_value=True) + # ``sessions`` is a mock, so the allocation reservation a real sibling cold + # start holds is not modelled here: these cases pin the slot-level probe + # (``_switch_target_busy``) alone, and the reservation probe answers free. + state.sessions.effort_basis_locked = MagicMock(return_value=False) + state.sessions.effort_intent_write = MagicMock( + side_effect=lambda _key: contextlib.nullcontext() + ) # The window under test: B's provider.start() has not registered yet. state.sessions.get_provider = MagicMock(return_value=None) # No transcript store: the control cases run the commit path to its 200, diff --git a/test/test_switch_handlers_slot_identity.py b/test/test_switch_handlers_slot_identity.py index 7e544457046..8023f6344b3 100644 --- a/test/test_switch_handlers_slot_identity.py +++ b/test/test_switch_handlers_slot_identity.py @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio +import contextlib from collections.abc import Callable from unittest.mock import AsyncMock, MagicMock @@ -84,6 +85,10 @@ def _mock_state(slot: _ChatSlot) -> DashboardState: state._slots = {slot.key: slot} state.sessions = MagicMock() state.sessions.reset = AsyncMock(return_value=True) + state.sessions.effort_basis_locked = MagicMock(return_value=False) + state.sessions.effort_intent_write = MagicMock( + side_effect=lambda _key: contextlib.nullcontext() + ) state.sessions.get_provider = MagicMock(return_value=_idle_provider()) return state diff --git a/test/test_unpinned_effort_binding.py b/test/test_unpinned_effort_binding.py index 6ca9b29969f..ee0d8d6578b 100644 --- a/test/test_unpinned_effort_binding.py +++ b/test/test_unpinned_effort_binding.py @@ -18,11 +18,12 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from test_effort import _read_cli_overlay from kiro_crew.acp.types import ACP_BACKEND_CLAUDE from kiro_crew.agent_sdk.backends import ACP_BACKEND_KIRO, ACP_BACKEND_PI from kiro_crew.config.loader import KiroCrewConfig -from kiro_crew.providers.acp import AcpProvider, _read_cli_overlay, _write_cli_overlay +from kiro_crew.providers.acp import AcpProvider, _write_cli_overlay _PI_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] @@ -32,9 +33,9 @@ def _provider( ) -> AcpProvider: """An unstarted provider whose client reports *served* once it is ready. - With *work_dir* the client is configured before construction, so the kiro - family seeds its effort map from a real overlay there, as on a reused - workspace. + With *work_dir* the client is configured before construction, as on a + reused workspace with a real overlay there, which construction does not + read. """ with patch("kiro_crew.providers.acp.AcpClient") as client_cls: if work_dir is not None: @@ -188,13 +189,13 @@ async def test_kiro_push_refused_leaves_no_level_stored(self): @pytest.mark.asyncio async def test_kiro_family_pushes_over_a_different_level_the_overlay_left(self, tmp_path): # A reused workspace whose overlay holds another level for the served - # model seeds the map at construction; the requested level still wins, + # model: construction adopts none of it, and the requested level wins, # live and in the overlay the next spawn reads. _write_cli_overlay(tmp_path, "claude-opus-4.7", "high") provider = _provider( ACP_BACKEND_KIRO, unbound="low", served="claude-opus-4.7", work_dir=tmp_path ) - assert provider._effort_per_model == {"claude-opus-4.7": "high"} + assert provider._effort_per_model == {} provider._client._resolved_model_id = "claude-opus-4.7" await provider._bind_unbound_effort() provider._client.send_command.assert_awaited_once_with("/effort", args={"level": "low"}) @@ -202,11 +203,11 @@ async def test_kiro_family_pushes_over_a_different_level_the_overlay_left(self, assert _read_cli_overlay(tmp_path) == {"claude-opus-4.7": "low"} @pytest.mark.asyncio - async def test_kiro_family_skips_the_push_when_the_overlay_already_holds_the_level( + async def test_kiro_family_skips_the_bind_push_when_the_level_is_already_stored( self, ): - # kiro-cli read that level from the overlay at spawn, so the session - # already runs it. + # An earlier start stored the level, and the post-spawn push carries it + # on every later start, so the bind does not change it again. provider = _provider(ACP_BACKEND_KIRO, unbound="high", served="claude-opus-4.7") provider._client._resolved_model_id = "claude-opus-4.7" provider._effort_per_model["claude-opus-4.7"] = "high" @@ -216,9 +217,9 @@ async def test_kiro_family_skips_the_push_when_the_overlay_already_holds_the_lev assert provider._effort_per_model == {"claude-opus-4.7": "high"} @pytest.mark.asyncio - async def test_kiro_push_refused_over_an_overlay_level_keeps_the_spawned_level(self): - # The session was spawned at the overlay's level, so a refused push must - # leave the map and the overlay naming that level, not the requested one. + async def test_kiro_push_refused_over_a_stored_level_keeps_that_level(self): + # A refused push must leave the map and the overlay naming the level + # stored before the bind, not the requested one. provider = _provider(ACP_BACKEND_KIRO, unbound="low", served="claude-opus-4.7") provider._client._resolved_model_id = "claude-opus-4.7" provider._effort_per_model["claude-opus-4.7"] = "high" @@ -229,6 +230,99 @@ async def test_kiro_push_refused_over_an_overlay_level_keeps_the_spawned_level(s # Written once for the attempt and once more for the rollback. assert provider._apply_effort_overlay.call_count == 2 + @pytest.mark.asyncio + async def test_kiro_restart_pushes_the_bound_level_after_spawn(self): + # A restart finds the level stored, so the bind pushes nothing. The + # post-spawn push reads the served model like every effort read, so the + # session runs the level whatever the file held at its spawn. + provider = _provider(ACP_BACKEND_KIRO, unbound="high", served="claude-opus-4.7") + provider._client._resolved_model_id = "claude-opus-4.7" + provider._effort_per_model["claude-opus-4.7"] = "high" + provider.change_effort = AsyncMock(return_value=True) + await provider._bind_unbound_effort() + await provider._reassert_projected_effort() + provider.change_effort.assert_not_awaited() + provider._client.send_command.assert_awaited_once_with("/effort", args={"level": "high"}) + + def test_kiro_restart_takes_the_auto_window_whatever_the_last_session_served(self): + # kiro-cli picks its model after it reads the overlay, so a restart with + # nothing pinned is projected as auto under the window fence, even when + # the last session served a model and its level is stored. + provider = _provider(ACP_BACKEND_KIRO, unbound="high", served="claude-opus-4.7") + provider._client._resolved_model_id = "claude-opus-4.7" + provider._effort_per_model["claude-opus-4.7"] = "high" + assert provider._effort_overlay_fence_mode() == "window" + + @pytest.mark.asyncio + async def test_kiro_restart_sweeps_another_chats_level_for_the_next_model( + self, tmp_path, monkeypatch + ): + # The last session served opus; the next may serve sonnet, for which + # another chat left an owned level. The projection before the spawn + # must remove it, as an auto spawn does, rather than write opus's. + _write_cli_overlay(tmp_path, "claude-sonnet-4.5", "low") + provider = _provider( + ACP_BACKEND_KIRO, unbound="high", served="claude-sonnet-4.5", work_dir=tmp_path + ) + provider._client._resolved_model_id = "claude-opus-4.7" + provider._effort_per_model["claude-opus-4.7"] = "high" + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + monkeypatch.setattr(provider, "_bind_unbound_effort", AsyncMock()) + monkeypatch.setattr(provider, "_reassert_projected_effort", AsyncMock()) + monkeypatch.setattr(provider, "_note_zero_tool_servers", AsyncMock()) + await provider.start() + assert _read_cli_overlay(tmp_path) == {} + + def test_kiro_restart_takes_the_auto_window_from_an_empty_client_model(self): + # After the first start the client is the swapped session provider, + # which records "" rather than the auto sentinel for a session with + # nothing pinned. The restart must still be projected as auto under the + # window fence; keying on the client's "" would fence nothing. + provider = _provider(ACP_BACKEND_KIRO, unbound="high", served="claude-opus-4.7") + provider._client._model = "" + provider._client._resolved_model_id = "claude-opus-4.7" + provider._effort_per_model["claude-opus-4.7"] = "high" + assert provider._effort_overlay_fence_mode() == "window" + + @pytest.mark.asyncio + async def test_kiro_restart_from_an_empty_client_model_sweeps_another_chats_level( + self, tmp_path, monkeypatch + ): + # The same sweep as above, from the "" the swapped session provider + # records: the projection must still remove sonnet's owned level rather + # than leave the file untouched for lack of a model to key on. + _write_cli_overlay(tmp_path, "claude-sonnet-4.5", "low") + provider = _provider( + ACP_BACKEND_KIRO, unbound="high", served="claude-sonnet-4.5", work_dir=tmp_path + ) + provider._client._model = "" + provider._client._resolved_model_id = "claude-opus-4.7" + provider._effort_per_model["claude-opus-4.7"] = "high" + monkeypatch.setattr(provider, "_start_kiro_runtime", AsyncMock()) + monkeypatch.setattr(provider, "_bind_unbound_effort", AsyncMock()) + monkeypatch.setattr(provider, "_reassert_projected_effort", AsyncMock()) + monkeypatch.setattr(provider, "_note_zero_tool_servers", AsyncMock()) + await provider.start() + assert _read_cli_overlay(tmp_path) == {} + + @pytest.mark.asyncio + async def test_kiro_warm_conversation_binds_the_level_to_a_changed_served_model(self): + # A fresh session on the warm process can serve another default model; + # the requested level is bound to it before the push, so the session + # runs that level instead of none. + provider = _provider(ACP_BACKEND_KIRO, unbound="high", served="claude-opus-4.7") + provider._client._resolved_model_id = "claude-opus-4.7" + provider._effort_per_model["claude-opus-4.7"] = "high" + provider._apply_effort_overlay = MagicMock(return_value=True) + + async def _fresh_session() -> None: + provider._client._resolved_model_id = "claude-sonnet-4.5" + + provider._client.new_conversation = AsyncMock(side_effect=_fresh_session) + await provider.new_conversation() + assert provider._effort_per_model["claude-sonnet-4.5"] == "high" + provider._client.send_command.assert_any_await("/effort", args={"level": "high"}) + @pytest.mark.asyncio async def test_kiro_session_on_auto_still_offers_no_effort(self): # kiro-cli reports currentModelId "auto" for an auto session and refuses