diff --git a/src/cli/work.rs b/src/cli/work.rs index b00c1d5c..ecd9f6a7 100644 --- a/src/cli/work.rs +++ b/src/cli/work.rs @@ -353,29 +353,28 @@ fn resolve_agent( })?; // Usage-threshold fallback candidate: only meaningful when the primary - // decision landed on claude-code. Re-running `route()` with claude-code - // excluded from `installed` reuses the exact rule that matched this - // item (same `task`), so the fallback honors whatever `[router]` - // preference order the user configured instead of hardcoding an agent. - // The `!= ClaudeCode` filter catches the case where the primary - // decision was an explicit human pin (`assigned_agent`) — `route()` - // returns that unconditionally regardless of `installed`, so the - // second call would otherwise just return claude-code again and look - // like a real fallback when there isn't one. Reuses the same `rotation` - // map as the primary call — this probe is a what-if, not a real pick, - // but a `rotate = true` rule's counter still needs to reflect it if the - // fallback is the one that actually runs (`pick_implementer_agent`). - let fallback_agent = if decision.agent == agent_registry::Agent::ClaudeCode { - let installed_minus_claude: Vec<_> = installed - .iter() - .copied() - .filter(|a| *a != agent_registry::Agent::ClaudeCode) - .collect(); - agent_registry::route(&task, config, &installed_minus_claude, rotation) - .map(|d| d.agent) - .filter(|fb| *fb != agent_registry::Agent::ClaudeCode) - } else { - None + // decision landed on a usage-gated agent (claude-code or opencode). + // Re-running `route()` with that agent excluded from `installed` reuses + // the exact rule that matched this item, so the fallback honors the + // configured `[router]` preference order instead of hardcoding an agent. + // The `*fb != excluded` filter catches an explicit human pin + // (`assigned_agent`) — `route()` returns that regardless of `installed`, + // so the second call would otherwise return the same agent again. The + // probe routes on a clone — a what-if must not advance the counter. + let fallback_agent = match decision.agent { + agent_registry::Agent::ClaudeCode | agent_registry::Agent::Opencode => { + let excluded = decision.agent; + let rest: Vec<_> = installed + .iter() + .copied() + .filter(|a| *a != excluded) + .collect(); + let mut fallback_rotation = rotation.clone(); + agent_registry::route(&task, config, &rest, &mut fallback_rotation) + .map(|d| d.agent) + .filter(|fb| *fb != excluded) + } + _ => None, }; Ok((decision.agent, decision.reason, fallback_agent)) @@ -384,7 +383,9 @@ fn resolve_agent( /// Combines `resolve_agent`'s router-derived fallback candidate with the /// live usage-threshold check into the agent actually used for the /// implementer role. `over_threshold` is only invoked when a fallback -/// exists and the primary decision is claude-code — see +/// exists; the caller picks which subscription's check to run based on the +/// primary decision (`claude_over_threshold` for claude-code, +/// `opencode_go_over_threshold` for opencode) — see /// `pick_implementer_agent_does_not_call_over_threshold_when_no_fallback_exists` /// for why that short-circuit matters. fn pick_implementer_agent( @@ -393,9 +394,7 @@ fn pick_implementer_agent( over_threshold: impl FnOnce() -> bool, ) -> agent_registry::Agent { match fallback_agent { - Some(fallback) if agent == agent_registry::Agent::ClaudeCode && over_threshold() => { - fallback - } + Some(fallback) if over_threshold() => fallback, _ => agent, } } @@ -829,11 +828,12 @@ fn execute_work_impl( return 1.into(); } }; - let implementer_agent = pick_implementer_agent( - agent_enum, - fallback_agent, - crate::claude_usage::claude_over_threshold, - ); + let over_threshold: fn() -> bool = match agent_enum { + agent_registry::Agent::ClaudeCode => crate::claude_usage::claude_over_threshold, + agent_registry::Agent::Opencode => crate::opencode_go_usage::opencode_go_over_threshold, + _ => || false, + }; + let implementer_agent = pick_implementer_agent(agent_enum, fallback_agent, over_threshold); // Judge/reviewer gets its own `role = "judge"` resolution, so a // `[router]` rule can pin it independently of the implementer's // rotation/usage-threshold fallback. No such rule (the common case) @@ -850,23 +850,25 @@ fn execute_work_impl( .map(|(agent, _, _)| agent) .unwrap_or(agent_enum); crate::state::save(&state); - if headless_args(agent_enum).is_none() { - let msg = format!("agent {} has no headless print mode", agent_enum.as_str()); + let name = implementer_agent.as_str(); + let agent_unchanged = implementer_agent == agent_enum; + if headless_args(implementer_agent).is_none() { + let msg = format!("agent {name} has no headless print mode"); release_and_comment(&mcp, item_id, &msg, args.notify.as_deref()); crate::ui::error(&msg); return 1.into(); } - let _ = writeln!(log, "agent: {} ({route_reason})", agent_enum.as_str()); + let _ = writeln!(log, "agent: {name} ({route_reason})"); let item_description = item_detail.description.clone(); let plan_doc = latest_plan_doc_content(&mcp, item_id); // --- Extra args --- let extra_args = build_extra_args( - agent_enum, + implementer_agent, args.max_turns, args.max_cost_usd, - args.model.as_deref(), + args.model.as_deref().filter(|_| agent_unchanged), ); // --- Change to worktree dir and run the sdd_loop -> finalize pipeline; @@ -926,7 +928,7 @@ fn execute_work_impl( release_and_comment(&mcp, item_id, &msg, args.notify.as_deref()); crate::ui::error(&msg); let _ = writeln!(log, "failed: {msg}"); - let retry_after_secs = classify_and_cooldown(agent_enum.as_str(), &msg); + let retry_after_secs = classify_and_cooldown(implementer_agent.as_str(), &msg); WorkOutcome { exit_code: 1, retry_after_secs, @@ -1383,16 +1385,13 @@ use = "claude-code" } #[test] - fn resolve_agent_fallback_is_none_when_primary_decision_is_not_claude_code() { + fn resolve_agent_fallback_picks_claude_code_when_opencode_is_primary() { + // Symmetric to the claude-code->opencode gate: an opencode primary + // yields a fallback candidate from the same rule's preference order. let mut item = test_item(); item.metadata = r#"{"size":"S"}"#.to_string(); let config = agent_registry::parse_router_config( - r#" -[router] -[[router.rule]] -when = { size = "S" } -use = "opencode" -"#, + "[router]\n[[router.rule]]\nwhen = { size = \"S\" }\nuse = [\"opencode\", \"claude-code\"]\n", ) .unwrap(); let (agent, _, fallback) = resolve_agent( @@ -1400,13 +1399,16 @@ use = "opencode" &item, &[], &config, - &[agent_registry::Agent::Opencode], + &[ + agent_registry::Agent::Opencode, + agent_registry::Agent::ClaudeCode, + ], None, &mut Default::default(), ) .unwrap(); assert_eq!(agent, agent_registry::Agent::Opencode); - assert_eq!(fallback, None); + assert_eq!(fallback, Some(agent_registry::Agent::ClaudeCode)); } #[test] @@ -1480,17 +1482,17 @@ rotate = true } #[test] - fn pick_implementer_agent_never_touches_a_non_claude_code_primary() { + fn pick_implementer_agent_falls_back_for_opencode_primary_too() { + // `pick_implementer_agent` is agent-agnostic: the per-agent gate + // lives at the call site (claude_over_threshold vs + // opencode_go_over_threshold). An opencode primary over threshold + // falls back exactly like a claude-code one. let agent = pick_implementer_agent( agent_registry::Agent::Opencode, - Some(agent_registry::Agent::Codex), + Some(agent_registry::Agent::ClaudeCode), || true, ); - assert_eq!( - agent, - agent_registry::Agent::Opencode, - "usage-fallback only ever applies when the primary decision is claude-code" - ); + assert_eq!(agent, agent_registry::Agent::ClaudeCode); } #[test] diff --git a/src/main.rs b/src/main.rs index 40722b7c..f11e311e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,7 @@ mod memory; mod mentions; mod mise_install; mod nudge_pace; +mod opencode_go_usage; mod optimize; mod paths; mod pm_mode; diff --git a/src/opencode_go_usage.rs b/src/opencode_go_usage.rs new file mode 100644 index 00000000..edc99c37 --- /dev/null +++ b/src/opencode_go_usage.rs @@ -0,0 +1,207 @@ +#![allow(dead_code)] // wired into resolve_agent's fallback gate in cli/work.rs + +//! Detects whether the active OpenCode Go subscription's usage (rolling +//! 5-hour, weekly, or monthly window) is at/over the fallback threshold, so +//! `resolve_agent`'s router-driven fallback (`src/cli/work.rs`) knows when +//! to prefer another installed agent CLI for the SDD loop's implementer +//! role — the OpenCode Go counterpart to `claude_usage`'s Claude Max +//! 5h/7d gate. +//! +//! OpenCode Go exposes its quota windows via the official +//! `GET https://opencode.ai/zen/go/v1/usage` endpoint, authenticated with +//! the same API key opencode stores locally in its `auth.json` (the +//! `opencode-go` provider). No OAuth refresh, no dashboard scraping. Fails +//! open (treats usage as "under threshold") on any missing key, network, or +//! parse error — a transient hiccup here must never block dispatch. + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const USAGE_URL: &str = "https://opencode.ai/zen/go/v1/usage"; +const FALLBACK_THRESHOLD_PERCENT: f32 = 70.0; +const CACHE_TTL: Duration = Duration::from_secs(300); + +/// Extracts the `opencode-go` API key from opencode's `auth.json` content — +/// the shape is `{"opencode-go": {"type": "api", "key": "sk-..."}}`. Any +/// other shape (missing provider, missing/empty/non-string key, invalid +/// JSON) is treated as "no key" rather than an error, so a file this parser +/// doesn't fully understand still fails open instead of hard-erroring. +fn parse_auth_key(text: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(text).map_err(|e| format!("invalid auth JSON: {e}"))?; + value + .get("opencode-go") + .and_then(|v| v.get("key")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| "no opencode-go API key in auth.json".to_string()) +} + +fn read_api_key() -> Result { + let path = crate::paths::opencode_auth_path(); + let text = std::fs::read_to_string(&path) + .map_err(|e| format!("could not read {}: {e}", path.display()))?; + parse_auth_key(&text) +} + +/// True when any of the three windows is at/over the fallback threshold. +fn is_over_threshold(rolling_percent: f32, weekly_percent: f32, monthly_percent: f32) -> bool { + rolling_percent >= FALLBACK_THRESHOLD_PERCENT + || weekly_percent >= FALLBACK_THRESHOLD_PERCENT + || monthly_percent >= FALLBACK_THRESHOLD_PERCENT +} + +/// Fetches the three usage percentages. Not unit tested directly (no +/// HTTP-mocking dependency in this crate — `claude_usage`'s `fetch_usage` +/// sets the same precedent of leaving the real network call untested and +/// testing only the pure logic around it). +fn fetch_usage_percentages(api_key: &str) -> Result<(f32, f32, f32), String> { + let agent = ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(10)) + .build(); + let response = agent + .get(USAGE_URL) + .set("Accept", "application/json") + .set("User-Agent", "opencode-go/1.0.0 (agentflare)") + .set("Authorization", &format!("Bearer {api_key}")) + .call() + .map_err(|e| format!("usage request failed: {e}"))?; + let body: serde_json::Value = response + .into_json() + .map_err(|e| format!("could not parse usage response: {e}"))?; + let usage = body + .get("usage") + .ok_or_else(|| "usage response missing \"usage\" field".to_string())?; + // `percent` is an integer on the wire today; parse defensively so a + // switch to fractional percentages doesn't silently read as 0. + let pct = |window: &str| -> f32 { + usage + .get(window) + .and_then(|w| w.get("percent")) + .and_then(|v| v.as_f64().or_else(|| v.as_i64().map(|i| i as f64))) + .unwrap_or(0.0) as f32 + }; + Ok((pct("rolling"), pct("weekly"), pct("monthly"))) +} + +struct CacheEntry { + over_threshold: bool, + fetched_at: Instant, +} + +static CACHE: OnceLock>> = OnceLock::new(); + +/// True when the active OpenCode Go account's rolling/weekly/monthly usage +/// is at/over the fallback threshold. Fails open (`false`) on any key-read, +/// network, or parse error. Cached 5 minutes so this never adds a network +/// call per SDD-loop turn. +pub fn opencode_go_over_threshold() -> bool { + let cache = CACHE.get_or_init(|| Mutex::new(None)); + { + let guard = cache.lock().unwrap(); + if let Some(entry) = guard.as_ref() + && entry.fetched_at.elapsed() < CACHE_TTL + { + return entry.over_threshold; + } + } + + let over_threshold = (|| -> Result { + let api_key = read_api_key()?; + let (rolling, weekly, monthly) = fetch_usage_percentages(&api_key)?; + Ok(is_over_threshold(rolling, weekly, monthly)) + })() + .unwrap_or(false); + + *cache.lock().unwrap() = Some(CacheEntry { + over_threshold, + fetched_at: Instant::now(), + }); + over_threshold +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_over_threshold_true_when_rolling_at_seventy() { + assert!(is_over_threshold(70.0, 10.0, 10.0)); + } + + #[test] + fn is_over_threshold_true_when_weekly_at_seventy() { + assert!(is_over_threshold(10.0, 70.0, 10.0)); + } + + #[test] + fn is_over_threshold_true_when_monthly_at_seventy() { + assert!(is_over_threshold(10.0, 10.0, 70.0)); + } + + #[test] + fn is_over_threshold_false_when_all_under_seventy() { + assert!(!is_over_threshold(69.9, 69.9, 69.9)); + } + + #[test] + fn parse_auth_key_reads_the_opencode_go_entry() { + let key = parse_auth_key(r#"{"opencode-go":{"type":"api","key":"sk-test"}}"#).unwrap(); + assert_eq!(key, "sk-test"); + } + + #[test] + fn parse_auth_key_ignores_other_providers_without_opencode_go() { + let err = parse_auth_key(r#"{"opencode":{"type":"api","key":"sk-other"}}"#).unwrap_err(); + assert!(err.contains("opencode-go")); + } + + #[test] + fn parse_auth_key_rejects_a_missing_key_field() { + let err = parse_auth_key(r#"{"opencode-go":{"type":"api"}}"#).unwrap_err(); + assert!(err.contains("opencode-go")); + } + + #[test] + fn parse_auth_key_rejects_an_empty_key() { + let err = parse_auth_key(r#"{"opencode-go":{"type":"api","key":""}}"#).unwrap_err(); + assert!(err.contains("opencode-go")); + } + + #[test] + fn parse_auth_key_rejects_invalid_json() { + let err = parse_auth_key("not json").unwrap_err(); + assert!(err.contains("invalid auth JSON")); + } + + #[test] + fn read_api_key_fails_open_when_file_is_missing() { + crate::paths::test_support::with_temp_home(|| { + let err = read_api_key().unwrap_err(); + assert!(err.contains("could not read")); + }); + } + + #[test] + fn read_api_key_reads_a_real_file() { + crate::paths::test_support::with_temp_home(|| { + let path = crate::paths::opencode_auth_path(); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, r#"{"opencode-go":{"type":"api","key":"sk-test"}}"#).unwrap(); + assert_eq!(read_api_key().unwrap(), "sk-test"); + }); + } + + // The only test exercising the cached wrapper directly — `CACHE` is a + // process-wide static, so a second test hitting `opencode_go_over_threshold` + // could observe the first test's cached result depending on `cargo + // test`'s thread scheduling. Keep it this way, mirroring claude_usage. + #[test] + fn opencode_go_over_threshold_fails_open_without_credentials() { + crate::paths::test_support::with_temp_home(|| { + assert!(!opencode_go_over_threshold()); + }); + } +} diff --git a/src/paths.rs b/src/paths.rs index c39650ce..a38b6d6f 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -83,6 +83,42 @@ pub fn opencode_plugin_dir() -> PathBuf { opencode_dir().join("plugin") } +/// OpenCode's data directory (where `auth.json` lives), distinct from the +/// config directory (`opencode_dir`) — mirrors opencode's own resolution: +/// `OPENCODE_DATA_DIR`, else `XDG_DATA_HOME/opencode`, else the platform +/// default (`~/.local/share/opencode` on Linux/Windows, +/// `~/Library/Application Support/opencode` on macOS). +pub fn opencode_data_dir() -> PathBuf { + if let Ok(dir) = std::env::var("OPENCODE_DATA_DIR") + && !dir.is_empty() + { + return PathBuf::from(dir); + } + if let Ok(dir) = std::env::var("XDG_DATA_HOME") + && !dir.is_empty() + { + return PathBuf::from(dir).join("opencode"); + } + #[cfg(target_os = "macos")] + { + home() + .join("Library") + .join("Application Support") + .join("opencode") + } + #[cfg(not(target_os = "macos"))] + { + home().join(".local").join("share").join("opencode") + } +} + +/// `auth.json` inside OpenCode's data directory — opencode's provider +/// credentials, including the `opencode-go` subscription API key the Go +/// usage tracker reads. +pub fn opencode_auth_path() -> PathBuf { + opencode_data_dir().join("auth.json") +} + /// Shared by mcp_server.rs (serving skill_search/skill_load) and /// components.rs (syncing skillOverrides) — same on-disk cache, single path /// definition so the two can never drift apart.