fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR - #4557
fix(managed-agents): apply Claude Code agent config via ACP/env; respect CLAUDE_CONFIG_DIR#4557wpfleger96 wants to merge 24 commits into
Conversation
d3662b8 to
952e5c4
Compare
…ect CLAUDE_CONFIG_DIR Fix five gaps in Claude Code agent configuration in Buzz Desktop. Buzz sets config via env vars at spawn and ACP messages at runtime; file layout on disk stays the owner's. Model (fixes #2692): ANTHROPIC_MODEL is injected at local claude spawn as the single startup model authority. BUZZ_ACP_MODEL is removed from the spawned env to prevent two simultaneous model authorities. Remote claude deploys receive ANTHROPIC_MODEL in policy_env, never BUZZ_ACP_MODEL. PermissionMode Auto (fixes #2884): adds the Auto variant to PermissionMode with wire string "auto" and tests. The adapter handles graceful downgrade when the active model does not support it. Effort end-to-end via ACP (B5): EffortPicker in the config panel discovers the thought_level configId from the session cache (never hardcoded) and calls set_config_option. The harness verifies the configId, forwards to the adapter, and emits an ack carrying category: "thought_level" only on a real forward. The observer persists the canonical value only on ok+category. At next session creation, desired_effort is applied via session_set_config_option so the persisted default takes effect on first turn after a restart. Honest acks: no fabricated ok anywhere. Synthetic acks (unknown configIds) carry no category so the observer cannot persist them. the agent's effective CLAUDE_CONFIG_DIR env var (record > persona > global), falling back to ~/.claude/ when unset. MCP config stays at ~/.claude.json regardless (CLAUDE_CONFIG_DIR does not remap the global MCP config file). The panel shows a Keychain caveat note when a custom dir is active: Claude keys its login to the config-dir path, so a custom dir creates a fresh Keychain namespace and the agent needs re-authentication unless the user also manages CLAUDE_SECURESTORAGE_CONFIG_DIR. Closes #2692, #2884, #3493 Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
952e5c4 to
c1397bf
Compare
…aude.json Finding 1 — effort startup-default glue: - Add BUZZ_ACP_EFFORT_LEVEL CLI arg + env var to buzz-acp config - Add startup_effort to PoolStartup and OwnedAgent structs - Extract OwnedAgent::resolve_startup_effort() method that arms desired_effort from startup_effort + capabilities-derived configId - Call resolve_startup_effort() at first session creation after capabilities are populated (replaces inline block) - Inject BUZZ_ACP_EFFORT_LEVEL at spawn in runtime.rs from record.effort_level - Fix stale doc comment in types.rs (was settings.json seeding language) Finding 2 — .claude.json path honors CLAUDE_CONFIG_DIR: - Fix claude.rs read_config_file: resolve .claude.json relative to config_dir when set, same as settings.json (binary does the same) - Fix reader.rs mcp_config_file_path_for_runtime to accept and use claude_config_dir for the claude case - Fix agent_config.rs CLAUDE_CONFIG_DIR lookup to use resolve_effective_agent_env instead of hand-rolled record chain that skipped definition-env tier and baked floor Tests added: - resolve_startup_effort arms desired_effort from startup_effort + configId - resolve_startup_effort does not override live pick - resolve_startup_effort is no-op when startup_effort absent - resolve_startup_effort is no-op when model lacks thought_level Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three test-mod Config{} literals in buzz-acp were missing the new
effort_level field, causing compilation failures under --all-targets
(Windows CI caught this; desktop gates run a separate workspace).
Add effort_level: None to all three.
Run cargo fmt --all and cargo fmt --manifest-path desktop/src-tauri/Cargo.toml
to fix the rustfmt diffs caught by CI Rust Lint and desktop-tauri-fmt-check.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…emote parity, option validation
Replace per-worker desired_effort/startup_effort with ONE pool-level
desired_effort (AgentPool::desired_effort). Closes I-2 (queued-as-ok),
I-3 (first-idle-only), and I-5 (spawn-drift) together.
Rust (crates/buzz-acp):
- AgentPool gains desired_effort field; set_idle_agent_effort replaced
by set_pool_effort (stores pool-level, invalidates all idle sessions,
returns Stored { invalidated }) and clear_pool_effort (sets None,
invalidates all idle).
- try_claim always copies pool.desired_effort onto the claimed agent.
- return_agent propagates startup-resolved effort back to pool level.
- create_session_and_apply_model emits honest final control_result ack
(ok/failure) after the real ACP call; no pre-persist on queued state.
- handle_set_config_option_control: clear path emits "cleared"; non-empty
path emits "pending_session"; I-7 validates value against adapter-
advertised options and emits "invalid_value" on mismatch.
- M-1: restore damaged PoolStartup doc comment.
- Tests: set_pool_effort_stores_and_invalidates, multi-worker convergence,
clear_pool_effort, test_b5_empty_value_emits_cleared_ack,
test_b5_invalid_value_emits_invalid_value_ack_and_does_not_update_pool.
TypeScript (desktop/src):
- effortOutcome.ts: awaitEffortOutcome helper — subscribes before send,
awaits correlated final result (ok/failure/invalid_value/cleared), falls
back to pending_session on timeout.
- effortOutcome.test.mjs: 13 tests covering all statuses, correlation,
cleanup, and deferred-path (pending_session → final ok).
- EffortPicker: uses awaitEffortOutcome; empty value = clear (I-1);
effortOptions from adapter (I-7); surfaces pending_session / failure /
invalid_value status messages; invalidates queries on ok/cleared.
- observerRelayStore: persist on ok+thought_level (final applied ack) OR
cleared+thought_level (Auto clear); skip all other statuses.
- types.ts: SetConfigOptionResult named type; effortOptions field on
RuntimeConfigSurface.
agents_deploy.rs (I-4): project record.effort_level → BUZZ_ACP_EFFORT_LEVEL
into remote policy_env, mirroring local spawn; positive/negative tests.
config_bridge (M-2/M-3): direct test for mcp_config_file_path_for_runtime
with custom CLAUDE_CONFIG_DIR; treat empty/blank CLAUDE_CONFIG_DIR as unset
in agent_config.rs (matches Claude's || homedir() semantics).
reader.rs: effort_options populated from session cache for claude runtime.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
V-1 (clear resurrection prevention): add effort_ever_picked flag to AgentPool. return_agent propagates a startup-resolved desired_effort to pool level ONLY when no live pick/clear has ever been made. A user clear sets effort_ever_picked=true; subsequent worker returns can no longer resurrect the cleared value. V-2 (all-busy capability loss): add PoolEffortCapabilities cache and capabilities_ever_discovered flag to AgentPool. Pool-level cache is written at return_agent (refreshed from the returning worker's capabilities) and via notify_capabilities_discovered. The handle_set_config_option_control path reads from the cache instead of scanning idle agent slots, so picks and clears are never silently dropped when all workers are checked out. V-3 (busy-worker session convergence): return_agent compares the worker's checkout snapshot (desired_effort) against the current pool value. If they differ (a pick or clear arrived while the worker was busy), the worker's sessions are invalidated so the next try_claim creates a fresh session under the current pool value. Tests added (pool.rs effort_tests): - test_v1_clear_while_busy_return_does_not_resurrect_cleared_effort - test_v2_pick_while_all_busy_is_stored_not_dropped - test_v3_busy_worker_sessions_invalidated_on_return_after_pick - test_startup_effort_propagates_to_pool_on_first_return_when_no_live_pick Existing tests updated (lib.rs control_result_tests, pool.rs effort_tests): four tests that construct AgentPool::from_slots with agents carrying model_capabilities now also call notify_capabilities_discovered to populate the pool-level cache, matching production behavior where the cache is written at return_agent time. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…scovery window Before this fix, a pick or clear sent during the first turn (before any worker returns and populates the pool capability cache) fell through to the synthetic-ok branch. `capabilities_ever_discovered` was false, `effort_capabilities.config_id` was None, and `is_thought_level` was false: the harness emitted a fabricated ok with no category, the value was never stored, and the EffortPicker reported success with nothing applied or persisted. Fix: - Desktop sends `category: "thought_level"` on all effort frames (sendSetConfigOption gains an optional category param; EffortPicker passes it). The harness uses this as the trust signal in the pre-discovery window (case D). - Harness (lib.rs): adds case D — `!capabilities_ever_discovered && frame_category == "thought_level" && configId != "unknown"` — to the `is_thought_level` check. Picks and clears in this window are stored and acked `pending_session`/`cleared` rather than synthetic ok. - pool.rs: removes the `NoCatalog` guard from `set_pool_effort` and the `NoCatalog` variant entirely. The caller already gates on `is_thought_level`; `set_pool_effort` always stores. Removes the unreachable `NoCatalog => pending_session` match arm from the handler. - `notify_capabilities_discovered` moved to `#[cfg(test)]` with an honest doc. In production the cache is written only at `return_agent`. All false doc claims (pool.rs:264, 306; lib.rs:1020-21) corrected. - Tests: two new case-D tests in lib.rs (`test_b5_pre_discovery_pick_with_category_stores_and_emits_pending_session`, `test_b5_pre_discovery_clear_with_category_emits_cleared_not_synthetic_ok`); existing NoCatalog tests rewritten to match new semantics (always stores). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ation (IMPORTANT 1/2/3) Addresses all three IMPORTANT findings from Thufir Pass 2 plus the extraction MINOR that feeds harness-side invalid_value validation in production. IMPORTANT 1 — provisional pick + rollback: - Added committed_effort field to AgentPool as the last adapter-confirmed baseline. Pending picks are provisional until the ACP call resolves. - return_agent: generation-matched Applied → commit; Failed → rollback to committed_effort so the failed candidate is never recopied by try_claim. - Stale-generation results (superseded by newer pick/clear) are discarded entirely — no commit, no rollback, pool unchanged. IMPORTANT 2 — per-request nonce correlation: - AgentPool.effort_generation incremented on every pick/clear; carried on checked-out agents as desired_effort_gen. Pool echoes it as a nonce field (from pending_effort_nonce, set from the Desktop's crypto.randomUUID() nonce) in all immediate and final acks. - Desktop: sendSetConfigOption gains optional nonce param; AgentConfigPanel generates crypto.randomUUID() per request and registers it before awaiting. - awaitEffortOutcome: nonce is the primary correlation key; rejects acks where frame.nonce !== nonce even if configId and value match (stale same-value picks). - observerRelayStore: persistence gate checks ackNonce === registered before persisting ok/cleared; backwards-compat — acks without nonce always pass. - Four new nonce correlation tests in effortOutcome.test.mjs. IMPORTANT 3 — two-phase clear: - handle_set_config_option_control: empty value now emits pending_session (non-terminal) instead of terminal cleared. Pool is cleared immediately so future sessions run without effort, but confirmation waits for adapter. - create_session_and_apply_model: new else-if branch — desired_effort=None but desired_effort_gen set → pending clear; emits final cleared ack with nonce+ category after the session creates without effort override. Observer persists null only on this final cleared. - Tests updated: test_b5_empty_value_emits_pending_session_ack and test_b5_pre_discovery_clear_with_category_emits_pending_session_not_synthetic_ok. MINOR — extract_agent_config_options (feeds harness invalid_value production): - New function in acp.rs that retains both category=="model" and category=="thought_level" entries from session/new configOptions. - AgentModelCapabilities.config_options_raw now populated via this function so the pool-level capability cache includes thought_level valid_values in production (not just tests). The harness invalid_value guard now runs for real adapter picks. MINOR — picker query invalidation: - AgentConfigPanel: on ok/cleared outcome, invalidates agentConfigSurfaceQueryKey (source of currentEffort) in addition to managedAgentsQueryKey so the panel reflects the new committed effort immediately. MINOR — restore damaged doc comment: - lib.rs test_b5_real_forward_ack_includes_thought_level_category: restored the middle line of the three-line doc comment that was dropped in a prior commit. Pool tests: 4 new (test_failure_rolls_back_desired_effort_to_committed, test_applied_commits_desired_effort_to_committed, test_stale_gen_failure_does_not_rollback_pending_pick, test_cleared_commits_none_to_committed_effort). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F-1: gate resolve_startup_effort on desired_effort_gen.is_none() resolve_startup_effort re-armed desired_effort from startup_effort whenever it was None, which could not distinguish 'never picked' from 'user just cleared'. A post-clear checkout carries desired_effort=None and desired_effort_gen=Some(N); the old guard would re-arm the startup value, fire the apply branch instead of the cleared branch, and emit an ok ack carrying the clear's nonce — causing the observer to persist the old value over the user's explicit clear, with perpetual V-3 churn. Fix: add && self.desired_effort_gen.is_none() so the startup-seeding path is only taken when no live pick/clear has ever been stored (gen never set). Startup seeding (gen None) still works; post-clear (gen Some) falls through to the cleared branch as intended. New test: test_resolve_startup_effort_noop_after_live_clear_gen_is_some F-2: emit real configId in final cleared ack (pool.rs:1465) The cleared ack hardcoded "effort" as the configId. awaitEffortOutcome checks frame.configId !== configId before the nonce, so any adapter whose thought_level configId differs would leave the clear promise unsettled and fall to the 8s timeout. Fix: read the configId from agent.model_capabilities (populated just above at line 1336), falling back to "effort" when capabilities are not yet populated (pre-discovery clear path). F-3: box PoolEvent::Wake large variant (lib.rs:1910) AgentPool grew past clippy's large_enum_variant threshold after the committed_effort, nonce, and capability-cache fields were added. CI Rust Lint and Windows Rust both failed on this branch at 333362a. Fix: Box<Result<AgentPool, String>> in the Wake variant; box on construction, unbox on match. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Resolve two conflicts: 1. crates/buzz-acp/src/config.rs: PermissionMode::BypassPermissions removed by main (#4609, security fix). Branch added PermissionMode::Auto and effort_level. Resolution: keep Auto and effort_level additions; drop BypassPermissions (aligns with #4609's intent that the variant cannot be restored by configuration). One test assertion for BypassPermissions.is_default() removed accordingly. 2. desktop/src-tauri/src/commands/agents_deploy.rs: branch added claude B2/I-4 tests (ANTHROPIC_MODEL routing, BUZZ_ACP_EFFORT_LEVEL); main added OpenClaw parallelism-cap tests via #4019. Resolution: keep both test sets; fix trailing blank line introduced by merge tool. Also: desktop/src-tauri/src/managed_agents/parallelism.rs (new in main via #4019) constructs ManagedAgentRecord in tests without the effort_level field added by this branch. Added effort_level: None to the test helper to satisfy the struct initializer. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Conflict resolution and compile fixes: - tests.rs: take main's side (fixture extracted to test_fixtures.rs by #4053), preserve our comment reword on marker_entry_is_namespaced_by_instance_id - test_fixtures.rs: auto-merged as main's version; add effort_level: None (same class as last round's parallelism.rs fix) No other ManagedAgentRecord literals required patching (cargo check clean). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add the Auto variant to PermissionMode (wire string 'auto'; #4557 adds the same variant from the claude-config arc — this commit establishes the contradiction logic ahead of that merge so the rebase is mechanical). Auto mode = fully autonomous execution; model-gated (requires supportsAutoMode); the adapter self-approves all tool calls internally and never emits session/request_permission. Mode matrix: - allow + auto → compatible (transmit as-is; both want unattended approval) - ask + auto → startup error (card never fires — ask becomes a dead letter) - reject + auto → startup error (inverted-security worst case: policy says deny while adapter silently auto-approves everything) Tests: 4 new pinned tests (allow+auto ok, ask+auto error, reject+auto error, wire string correct). Total: 724 passing. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
kalvinnchau
left a comment
There was a problem hiding this comment.
[P2] Include the request nonce in invalid_value acknowledgements — crates/buzz-acp/src/lib.rs:1320
The validation-rejection branch returns before the common acknowledgement builder adds nonce. Desktop always supplies a nonce and awaitEffortOutcome rejects every acknowledgement whose nonce does not match, so a genuinely invalid selection is ignored until the 8-second timeout and then presented as “Applies at next session,” even though the pool was deliberately left unchanged. Add the incoming nonce to this early acknowledgement (and cover the real nonce-bearing invalid-value path rather than only nonce-free fixtures).
[P2] Seed committed_effort when the startup effort is successfully applied — crates/buzz-acp/src/pool.rs:791
A persisted startup effort is applied by create_session_and_apply_model, but its checkout has desired_effort_gen == None; return_agent copies it into desired_effort and never records it in committed_effort. If the user subsequently selects another valid effort and the adapter rejects/times out, the failure path rolls desired_effort back to committed_effort, which is still None, instead of restoring the previously confirmed startup value. Record a successful startup application as the committed baseline (or initialize the pool baseline from the persisted value once capabilities confirm it), and add a startup-success → live-pick-failure regression.
[P2] Serialize generation resolution across parallel workers — crates/buzz-acp/src/pool.rs:805
Every worker checked out for the same generation independently emits a terminal acknowledgement and independently commits or rolls back the shared pool state. With parallelism >1, if worker B fails and returns first it rolls desired_effort back; if worker A then returns success, it updates only committed_effort and leaves desired_effort rolled back. The observer can persist A's ok, while future checkouts receive the old/default value. The reverse return order produces a different result, so pool state is scheduler-dependent. Resolve each generation once with explicit aggregate semantics (and emit one terminal outcome), or ensure a later same-generation success restores desired_effort consistently; cover both return orders with two workers.
Validation at exact PR HEAD b7c99fb94808cf3b07bb7540121d8eb4513578dd: cargo test -p buzz-acp --lib passed (723 tests); node --test src/features/agents/lib/effortOutcome.test.mjs passed (15 tests). These paths are not covered by the current suite.
P2-1 — invalid_value ack echoes nonce (lib.rs) The early-return rejection branch built its own ack before the common nonce-attaching builder ran. The parsed nonce was in scope but never added. Desktop's awaitEffortOutcome nonce guard rejects a nonce-less invalid_value ack, causing an 8 s timeout instead of an immediate rejection. Fix: attach nonce in the early-return branch when present. New test: test_b5_invalid_value_ack_echoes_nonce. P2-2 — committed_effort seeded on startup application (pool.rs) try_claim hands out desired_effort_gen = None for startup agents, so return_agent's generation-matched commit block never fires. A later rejected live pick rolled back to None instead of the confirmed startup value. Fix: when the V-1 startup propagation block runs and the result is Applied, seed committed_effort alongside desired_effort. Regression test: test_startup_success_seeds_committed_effort_for_rollback. P2-3 — same-generation resolution order-independent (pool.rs) With parallelism > 1, multiple workers carry the same generation. Applied wrote only committed_effort; Failed rolled back only desired_effort; each emitted a terminal ack. Pool state and the ack stream both depended on return order. Fix: first terminal result for a generation emits the ack and marks the gen resolved (last_acked_effort_gen); subsequent same-gen returns update pool state (commit/rollback) but emit no ack. Semantics documented at the resolution site. Two-worker tests: test_p2_3_fail_then_success and test_p2_3_success_then_fail. P3 fold-in — stale capability cache cleared on model swap (pool.rs) When a returning agent had populated capabilities but no thought_level configId (model swapped to a non-effort model), the pool-level effort_capabilities cache retained stale valid_values. Fix: clear the cache in the else-if branch; capabilities_ever_discovered stays true so case C (workers busy) remains active. New test: test_p3_capability_cache_cleared_on_non_effort_model_return. P3 fold-in — dead case-C branch resolved (lib.rs) After the model-swap clear, capabilities_ever_discovered = true AND effort_capabilities.config_id = None is now a reachable state (all workers checked out after a model swap), making all_busy_with_known_caps genuine. No code change — the earlier capability-cache clear made this case reachable; updated comment confirms it. P3 fold-in — nonce-gate bypass closed (observerRelayStore.ts) A nonce-less ok/cleared ack bypassed the stale-result guard when a nonce had previously been registered. Fix: once a nonce is registered for an agent, only acks with a matching nonce pass; a missing ack-nonce is treated as non-matching. Pre-any-pick startup path preserved: when no nonce has ever been registered, a nonce-less ack still passes through. Also: resetAgentObserverStore now clears currentEffortNonce so test isolation is not broken by residual nonce state. New test file: observerRelayNonceGate.test.mjs (7 cases: startup path, registered path, mismatch, bypass fix, per-agent isolation, reset). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: Alert community owners and admins when a new key joins (#4900) fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086) chore(hooks): run desktop typecheck in pre-push (#5110) feat(identity): recover desktop identity from a signed-in phone (#4845) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…ust ack Unify commit/rollback and terminal ack under a single first-terminal-wins gate. Previously the two blocks ran independently: the commit/rollback block executed for every same-gen return while only the ack block was gated on last_acked_effort_gen, producing order-dependent pool state. New semantics: the first same-gen return whose result is terminal (Applied / Failed / Cleared) performs the commit-or-rollback AND emits the ack AND marks the generation resolved. Later same-gen returns touch neither state nor the ack stream — the Desktop-visible ack and the pool state it reflects are resolved atomically by the same return. V-3 discarded-failed special case: a second-returning Failed worker may carry desired_effort == pool.desired_effort (values match after the first worker Applied and committed). V-3's equality check would skip invalidation, but that worker's live session ran at DEFAULT effort — stale. Force-invalidate when a same-gen return is discarded and the worker's result was Failed, regardless of the value comparison. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
F2 (pre-prompt pool-authority ack): the EffortReport channel/resolve_effort_report seam is now fully wired and the P2-3 tests updated to match. Previously the tests still called return_agent expecting it to emit acks, but acks moved to resolve_effort_report (PoolEvent::EffortReport arm) which fires pre-prompt. Fix: update test_applied/cleared/failure/p2_3 to call pool.resolve_effort_report with a real observer before return_agent; add #[allow(clippy::too_many_arguments)] on create_session_and_apply_model and run_prompt_task (effort_report_tx is the 8th arg). F1 (stale-gen force-invalidation): code was correct at tip; add three regression tests: rapid high->medium->high value-match stranding, repeated-clear None==None hole, and stale-gen-Failed DEFAULT-effort session surviving on value match. F3 (durable nonce registration): registerEffortNonce now also writes to localStorage (key: buzz:effort-nonce:<normalized-pubkey>). loadNoncesFromStorage runs at module init and after every resetAgentObserverStore, so the nonce survives Desktop restart and community-switch. _testClearEffortNonceStorage test helper exposes full logout cleanup. Tests updated with localStorage stub + two new cases: reset-then-replay (post-reset ack accepted) and full-reset (startup path restored). Minor 1: save worker_b.index before return in test_p2_3_success_then_fail and assert pool.agents_mut()[worker_b_index] — the old iter().flatten().next() read slot 0 (worker A), making the discarded_failed invalidation assertion vacuous. Minor 2: extract effortNonceMatches() pure predicate shared by dispatchControlResult and _testNonceGate; delete the duplicate inline logic from _testNonceGate. Minor 3: update five stale doc-comment references that still named create_session_and_apply_model as the ack source (lib.rs, pool.rs x2, observerRelayStore.ts, effortOutcome.ts) to point at pool.resolve_effort_report / PoolEvent::EffortReport. PR body: add tri-state Unknown|Supported|Unsupported capability representation as tracked follow-up in the Scope explicitly excluded section. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Replace the three-condition discarded_failed predicate (which required last_acked_effort_gen == Some(g) — false in the return→report race) with a single unconditional rule: any returned worker whose last_effort_result is Some(Failed) invalidates its sessions regardless of generation state, value equality, or whether resolve_effort_report has already run. Rationale: a Failed application means session_set_config_option rejected the pick; the live session ran at the agent default, not the requested value. The session is stale in both orderings — no gate state is needed. Worst case is one redundant session re-creation when the rollback target equals the default. Two new regression tests cover both orderings: - test_failed_worker_return_before_report_invalidates_session (the race): return_agent runs while generation is unresolved; session must still be invalidated; pre-fix this test fails at the sessions.is_empty() assert. - test_failed_worker_report_before_return_invalidates_session (normal path): resolve_effort_report fires first (pre-prompt); return_agent follows; session still invalidated; no second ack emitted. Both tests assert: failed session empty, exactly one terminal failure ack with the correct nonce, pool state (desired=low/committed=low) matching ack. F1 tests extended through resolution: after the invalidation assert, the high→medium→high and repeated-clear tests now perform the next claim and simulate a resolve_effort_report, asserting exactly one terminal ack carrying the newest nonce and persistence-eligible pool state. Minor 2: fix 4 remaining stale doc-comment references in pool.rs (~1282, ~1378) and lib.rs (~8145, ~8470) that still named create_session_and_apply_ model as the final-ack source; updated to PoolEvent::EffortReport → resolve_effort_report. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…motely
Fixes P2-1: BUZZ_ACP_EFFORT_LEVEL was written before descriptor.env, so
a user-supplied value in agent/persona/global env silently overrode the
canonical persisted effort_level. The panel and persistence continued to
report the canonical value while the harness started at the user-supplied
one — the same silent-divergence class the PR exists to eliminate.
Local fix (runtime.rs): extract apply_effort_env() and call it after the
descriptor.env loop, mirroring the A1 model-authority pattern exactly.
When effort_level is Some, the canonical value overwrites any user-supplied
entry. When None, the call is a no-op so user env from the loop survives
as legitimate startup seeding (no canonical to contradict).
Remote fix (agents_deploy.rs): when a canonical effort_level is present,
strip BUZZ_ACP_EFFORT_LEVEL from launch.env before serialisation. In the
k8s three-tier model, tier 2 (launch.env) overwrites tier 1 (policy_env)
via later-wins — so the key must be absent from tier 2 whenever a
canonical value is already authoritative in tier 1. The strip is done on
the Desktop side, preserving the k8s engine's declared semantics rather
than adding a special case to build_env.
Tests: three local tests via apply_effort_env() in claude_config/tests.rs
(collision canonical wins, basic injection, None passthrough preserved)
and two remote tests in agents_deploy.rs (collision strips tier-2 entry,
None lets user entry survive in launch.env).
Ownership: desktop/src-tauri/src/managed_agents/runtime.rs,
claude_config/{mod,tests}.rs, commands/agents_deploy.rs.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…gaps * origin/main: (26 commits) fix(sdk): preserve self-mention p tags in message and forum event builders (#4975) bump @tauri-apps/cli to ~2.11.4 to fix linux app icon issue (#4858) feat(desktop): adding rich link previews to messages (#3818) fix(buzz-agent): Responses reasoning summary, Anthropic display:summarized, ACP v2 messageId (#5195) fix(desktop): retain distinct agent instances in autocomplete (#5202) fix(desktop): defer channel visibility change to Save (#5203) feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073) refactor(cli): replace probe/decider/detail split with single typed extractor (#5191) fix(desktop): drop unhandled rejection from throwing window.Notification (#5143) fix(desktop): fence localStorage SecurityError from killing the React tree (#5142) fix(desktop): make terminal output selectable (#4980) fix(desktop): use WEBKIT_DMABUF_RENDERER_FORCE_SHM for NVIDIA/AppImage (#3654) (#4505) Make public starter channels best effort (#5192) Mobile: add anchored reaction popover (#5025) feat(mobile): add bee pull-to-refresh (#5059) Remove agent creation success modal (#5063) fix(buzz-agent): escalate LLM timeouts per retry and log per-call latency (#5130) fix(agent): resolve oauth cache home cross-platform (#5151) Improve video review readiness and controls (#5161) Polish advanced agent setup and Welcome composer (#4926) ... Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
…ches
Replace the populate-once PoolEffortCapabilities cache + capabilities_ever_discovered
bool pair with a three-state EffortCapabilityState enum:
Unknown — no session has returned capabilities yet (pre-first-session window,
or capabilities reset after a model switch)
Supported { config_id, valid_values } — current model advertises thought_level
Unsupported — current model confirmed to not have thought_level
The old model populated capabilities once (on the first session/new) and never
refreshed them. After a live model switch — which resets agent.model_capabilities to
None to discard the pre-switch snapshot — return_agent wrote Unknown/kept stale state,
so handle_set_config_option_control kept validating against the old model's option set
and session_config_captured emitted pre-switch configOptions to Desktop.
Changes:
pool.rs:
- EffortCapabilityState enum replaces PoolEffortCapabilities + capabilities_ever_discovered
- return_agent derives tri-state from the returning worker's model_capabilities on
every return (not just the first), so the pool state always reflects the current model
- AgentPool initialises with Unknown; struct fields updated accordingly
pool.rs (create_session_and_apply_model):
- Capabilities are populated from EVERY session/new response, not just when None
- After a successful apply_model_switch, agent.model_capabilities is reset to None so
return_agent writes Unknown rather than stale-Supported
pool.rs (session_config_captured):
- On the switch path, configOptions emitted as null so Desktop clears its picker
state rather than caching pre-switch capability data
lib.rs (handle_set_config_option_control):
- Four-way A/B/C/D classifier replaced with a three-way tri-state dispatch
- Supported(config_id matches) → validate + store (case A)
- Supported(config_id no-match) → synthetic ok, non-effort (case B)
- Unsupported → reject with unsupported_model status (eliminates case-C
occupancy inference: capability loss is now surfaced immediately)
- Unknown → pre-discovery trust path: category=thought_level stores + pending_session
pool.rs field comment:
- last_acked_effort_gen comment updated to name resolve_effort_report as the sole
ack authority (Thufir Pass 3 non-blocking MINOR)
Tests (lib.rs control_result_tests):
- pool_with_capabilities_via_return helper drives the PRODUCTION return_agent path,
not the test-only notify_capabilities_discovered shortcut
- test_tristate_switch_effort_to_no_effort_rejects_pick: effort→no-effort switch
writes Unsupported; subsequent pick is rejected with unsupported_model status
- test_tristate_switch_no_effort_to_effort_accepts_pick: no-effort→effort switch
writes Supported with new configId; pick validates and stores correctly
- test_tristate_switch_new_options_validates_against_new_snapshot: both models
Supported with different option sets; stale value rejected, new value accepted
- test_tristate_unknown_after_switch_uses_pre_discovery_trust_path: capabilities=None
on return writes Unknown; pre-discovery category trust path remains active
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…gaps * origin/main: (35 commits) feat(desktop): time-based sweep for stale localStorage caches (#5453) ci(release): gate OSS desktop auto-update promotion (#5398) fix(release): pin desktop PR operations to block/buzz (#5212) fix(search): surface exact short profile names (#5480) Reduce repeated ACP session context (#5423) feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 (#4000) fix(desktop): resolve overlapping member mentions (#5225) chore(deps): update react monorepo (#4441) ci(security): allow retired relay pool advisory (#5404) chore(deps): update dependency @tanstack/react-virtual to v3.14.9 (#4439) chore(deps): update all non-major dependencies (#3049) chore(deps): update rust crate anyhow to v1.0.104 (#4447) chore(deps): update rust crate arc-swap to v1.9.2 (#4448) chore(deps): update rust crate async-trait to v0.1.91 (#4458) chore(deps): update rust crate diffy to v0.5.1 (#4466) chore(deps): update rust crate async-compression to v0.4.43 (#4456) chore(deps): update rust crate clap to v4.6.6 (#4465) fix(desktop): preserve Welcome banner dismissal (#5406) fix(agent): retry LLM completion on malformed 2xx JSON body (#5351) fix(desktop): welcome banner overlap and missing dismiss control (#5330) ... # Conflicts: # crates/buzz-acp/src/config.rs Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
#5423 (Reduce repeated ACP session context) added four lifecycle tests that construct OwnedAgent and call run_prompt_task with the pre-effort shape. The tri-state effort work on this branch added five fields to OwnedAgent and an effort_report_tx parameter to run_prompt_task, so the textual merge compiled the library but broke these cfg(test) targets. Add the five effort fields (all None, matching every other test-side construction) and thread an effort_report_tx into each call, mirroring the production call site in lib.rs. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
49177be to
756ed48
Compare
…d_model A successful Claude model switch discarded the RPC result and reset model_capabilities to None, so the switched session never yielded a post-switch snapshot: the effort picker stayed empty (Unknown) and resolve_startup_effort armed against the pre-switch default model. apply_model_switch now returns the RPC value. On a switch that echoes authoritative configOptions (claude-agent-acp 0.60.0 rebuilds session.configOptions on a model change), model_capabilities, the session_config_captured cache, and startup-effort resolution all converge on the target model. Startup effort resolves after the switch. When the adapter returns no options (or an application-level failure returns Null), only that fallback drops to Unknown. The pool moves to Unknown at switch dispatch on both idle and busy paths so a concurrent effort pick can't validate against the outgoing model's snapshot. Desktop treated the harness unsupported_model effort rejection as non-terminal, timing out to "Applies at next session" — the opposite of a capability rejection. awaitEffortOutcome now settles it immediately and EffortPicker renders a direct model-capability error. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…cted switch outcome Finding A: clear model_capabilities in SwitchModel worker handler so return_agent preserves Unknown instead of restoring the outgoing model's stale Supported snapshot. The pool was already set to Unknown in handle_switch_model_control; the worker's own cleared field prevents the cancelled return from overwriting it before the requeued switch lands. Finding B: introduce ModelSwitchOutcome enum (Applied/Rejected) to replace the overloaded Value::Null sentinel. On Rejected, the pre-switch capabilities and cache are preserved (session is still on default model), switch_succeeded is false, and a terminal control_result failure is emitted so Desktop ModelPicker rejects the live pick immediately. The optionless-success and rejection paths are now distinct: optionless success drops to Unknown, a rejection preserves existing capabilities. Two lifecycle regressions added (no manual capability injection): - test_busy_switch_clears_capabilities_so_return_preserves_unknown: starts Supported, simulates SwitchModel handler clearing caps, calls return_agent, asserts Unknown preserved. - test_switch_rpc_rejection_preserves_pre_switch_capabilities: scripted ACP returning a JSON-RPC error from the switch RPC; asserts pre-switch caps intact, modelOverridden false, control_result failure emitted. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n_prompt_task The Finding A regression previously hand-built a worker with cleared capabilities and called return_agent directly, so deleting the production fix line left it green — it proved a state transition the handler does not generate. Rewrite it to run run_prompt_task with a scripted ACP that holds the prompt open until session/cancel, delivering ControlSignal::SwitchModel over the biased select! so the real handler clears model_capabilities. Seed channel_info from cache and point the REST client at a fast local stub returning [] so the profile lookup does not retry twice against a dead base_url (~13s -> ~0.5s). Also document the multi-worker tri-state coarseness at the return_agent refresh block (bounded by the ratified F1/F2 effort-rejection machinery; no switch epoch added) and correct the rejection test's closing comment. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The Rust pool now emits control_result { type: "switch_model", status:
"failure" } when an adapter explicitly rejects a live model switch, but
awaitLiveSwitchOutcome treated only unsupported_model as failure — every
other status, including the new failure, counted toward success and
resolved "ok". ModelPicker then toasted "Model switched for this
session." on a switch that never happened.
Add a distinct terminal "failed" outcome that fail-fasts on a single
failure frame, kept separate from "unsupported" because the user-facing
causes differ (model unavailable vs adapter refused). ModelPicker shows
a direct failure toast and skips onModelChanged.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fixes five agent-config gaps for Claude Code managed agents in Buzz Desktop.
What this changes
A1 — Model authority for local and remote Claude spawns
apply_claude_model_envinruntime.rssetsANTHROPIC_MODELfrom the agent's effective model and removesBUZZ_ACP_MODELfrom the child env (Desktop owns model selection; ACP is the live-switch channel only). Remote Claude deploys addANTHROPIC_MODELtopolicy_envviaagents_deploy.rs;BUZZ_ACP_MODELis never injected remotely. Contract tests cover both the local inject-and-remove path and the remote Claude/non-Claude cases.B5 — Effort end-to-end via ACP
Pool-level desired effort. A single
AgentPool::desired_effortis the sole authority.set_pool_effortstores the value and invalidates all idle sessions;clear_pool_effortresets toNoneand also invalidates. Everytry_claimcopies the pool's current value onto the checked-out agent so all workers converge immediately — busy workers apply it at their next session.Provisional picks with commit/rollback.
AgentPool::committed_efforttracks the last adapter-confirmed value. A pick is provisional untilcreate_session_and_apply_modelresolves: onok→ commitcommitted_effort; onfailure→ roll backdesired_efforttocommitted_effortso the failed candidate is never recopied bytry_claimor retried. A monotoniceffort_generationcounter letsreturn_agentdistinguish the current pick from superseded ones — stale-generation results are discarded without touching committed state.Startup default.
record.effort_levelis injected asBUZZ_ACP_EFFORT_LEVELat local spawn (runtime.rs) and intopolicy_envat remote deploy (agents_deploy.rs). The harness reads it intoPoolStartup.startup_effort;resolve_startup_effort()armsdesired_effortafter capabilities arrive at the first session creation, using the capabilities-derivedthought_level_config_id.return_agentpropagates a startup-resolved effort back to pool level only when no live pick or clear has ever been made (effort_ever_picked = false), preventing resurrection of a cleared value.Canonical effort wins over user env (env-authority contract).
record.effort_level(the canonical persisted value) wins over any user-suppliedBUZZ_ACP_EFFORT_LEVELin agent definition/global/persona env, both locally and remotely. Locally:apply_effort_env()is applied after thedescriptor.envloop inruntime.rs, mirroring the A1 model-authority pattern — a canonicalSomevalue always overwrites any conflicting env entry. Remotely:agents_deploy.rsstripsBUZZ_ACP_EFFORT_LEVELfromlaunch.envwhen a canonical value is present, then places the canonical value inpolicy_env(tier 1, always wins overlaunch.envtier 2). Whenrecord.effort_levelisNone, user env passthrough is preserved — it merely seeds startup effort with no canonical value to contradict.Tri-state capability model.
EffortCapabilityState { Unknown | Supported { config_id, valid_values } | Unsupported }replaces the former populate-onceOption<PoolEffortCapabilities>+capabilities_ever_discoveredbool pair. Capabilities are re-derived from everysession/newresponse (not just the first) and after everyapply_model_switch(which resetsagent.model_capabilitiestoNonesoreturn_agentwritesUnknownrather than stale-Supported). Thehandle_set_config_option_controlclassifier is now a three-way dispatch:Supported→ validate + store,Unsupported→ reject withunsupported_modelstatus (capability loss surfaced immediately, no silent queue),Unknown→ pre-discovery trust path (category field). On the switch path,session_config_capturedemitsnullforconfigOptionsso Desktop clears its picker state rather than caching pre-switch data.Two-phase acks for both pick and clear.
handle_set_config_option_control:pending_sessionimmediate ack (pool stored); finalok/failurearrives pre-prompt viapool.resolve_effort_report(PoolEvent::EffortReportarm in the main loop) aftersession_set_config_optionresolves.pending_sessionimmediate ack (pool cleared toNone); finalclearedack arrives pre-prompt via the samePoolEvent::EffortReportpath when the session runs without an effort override.invalid_valueack; pool unchanged.Per-request nonce correlation. The Desktop generates a
crypto.randomUUID()nonce per pick/clear and passes it in the control frame. The harness echoes it in all acks (immediate and final).awaitEffortOutcomeuses the nonce as the primary correlation key, rejecting acks whereframe.nonce !== nonce; this prevents a stale ack from a same-value pick arriving late or from a superseded request settling the wrong promise. The observer persistence gate also checks the registered nonce, so late acks (e.g. after the 8s timeout) cannot overwrite a newer persisted value.Observer persistence.
dispatchControlResultpersists onok + thought_level(final applied) orcleared + thought_level(final clear confirmed); all other statuses (includingpending_sessionandfailure) do not persist. The nonce gate adds a second layer: mismatched nonces are silently dropped.EffortPicker. Subscribes to
control_resultbefore sending (no dropped acks), awaits the correlated final result viaawaitEffortOutcomewith an 8-second timeout, surfacespending_session/failure/invalid_valuestatus messages, and invalidates bothmanagedAgentsQueryKeyandagentConfigSurfaceQueryKeyonok/cleared. Options come from the adapter-advertisedeffortOptions(exported throughRuntimeConfigSurface;extract_agent_config_optionsretains both"model"and"thought_level"entries so the pool-level capability state and the harnessinvalid_valueguard work in production); falls back tolow/medium/highfor older adapters.PermissionMode
Autoconfig.rsaddsAutoto thePermissionModeenum and tests coverage incrates/buzz-acp.#3493 — Respect user-set
CLAUDE_CONFIG_DIRconfig_bridgeresolves bothsettings.jsonand.claude.jsonpanel paths from the agent's effective env viaresolve_effective_agent_env(full tier chain: baked floor → definition env → global → persona → record).mcp_config_file_path_for_runtimehonors the resolved custom dir; empty/blank values are treated as unset (matching Claude'sCLAUDE_CONFIG_DIR || homedir()semantics).agent_config.rsroutes through the same effective-env path so the panel and the process cannot diverge on which dir is active.AgentConfigPanelshows a Keychain-logout caveat when a custom dir is in effect (Claude behavior, not Buzz's).Scope explicitly excluded
Per-agent config dir provisioning,
CLAUDE_SECURESTORAGE_CONFIG_DIRsentinel injection, settings.json projection, protected-key stripping, B8 MCP inheritance, spawn serialization, andlast_spawn_warningssurface are absent from this diff. Silent-fallback machinery for non-Claude runtimes (#2265/#4004) is a tracked follow-up.Closes #2692, #2884, #3493