|
| 1 | +# Probe Design Addendum: Per-(CLI, provider, model) Health |
| 2 | + |
| 3 | +**Status**: Draft addendum to `.docs/design-adf-self-healing-2026-05-23.md` |
| 4 | +**Date**: 2026-05-23 |
| 5 | +**Trigger**: Z.AI investigation showed a provider can be **healthy via one CLI and broken via another**. Current probe is keyed by `(provider, model)`; it cannot represent this. |
| 6 | + |
| 7 | +## Evidence |
| 8 | + |
| 9 | +Direct invocation on bigbox 2026-05-23, same minute, same env: |
| 10 | + |
| 11 | +| Route | Result | |
| 12 | +|---|---| |
| 13 | +| `opencode run -m zai-coding-plan/glm-5.1` | step_start only -> silence -> 60 s TIMEOUT | |
| 14 | +| `pi-rust --provider zai-coding-plan --model glm-5.1 -p "ping"` | "Pong! 🏓" in ~3 s | |
| 15 | + |
| 16 | +All four `zai-coding-plan/*` models reproduce the same divergence. Other providers (kimi, minimax) work through both CLIs. |
| 17 | + |
| 18 | +## Problem |
| 19 | + |
| 20 | +`provider_probe` keyed by `(provider, model)` cannot distinguish: |
| 21 | +- `(zai-coding-plan, glm-5.1)` over opencode -> unhealthy |
| 22 | +- `(zai-coding-plan, glm-5.1)` over pi-rust -> healthy |
| 23 | + |
| 24 | +The KG router has two routes pointing at the same `(provider, model)` but with different `action::` templates. The probe currently marks the *provider/model* unhealthy and drops both routes. We lose the healthy one. |
| 25 | + |
| 26 | +## Proposed Design |
| 27 | + |
| 28 | +### Key change |
| 29 | + |
| 30 | +Probe key becomes `(cli_binary_basename, provider, model)` -- a 3-tuple. |
| 31 | + |
| 32 | +```rust |
| 33 | +// Before |
| 34 | +pub struct ProbeKey { provider: String, model: String } |
| 35 | + |
| 36 | +// After |
| 37 | +pub struct ProbeKey { |
| 38 | + cli: String, // "opencode", "pi-rust", "claude" -- basename only |
| 39 | + provider: String, |
| 40 | + model: String, |
| 41 | +} |
| 42 | +``` |
| 43 | + |
| 44 | +### Action template parsing |
| 45 | + |
| 46 | +`RouteDirective` (in `kg_router.rs`) already has the `action::` template. Extract the CLI basename at parse time: |
| 47 | + |
| 48 | +```rust |
| 49 | +impl RouteDirective { |
| 50 | + /// Returns the basename of the first whitespace-delimited token in |
| 51 | + /// the action template, e.g. "opencode" from |
| 52 | + /// "/home/alex/.bun/bin/opencode run -m {{ model }} ..." |
| 53 | + pub fn cli_basename(&self) -> Option<&str> { |
| 54 | + self.action.as_deref() |
| 55 | + .and_then(|a| a.split_whitespace().next()) |
| 56 | + .and_then(|p| std::path::Path::new(p).file_name()) |
| 57 | + .and_then(|f| f.to_str()) |
| 58 | + } |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +### Probe execution |
| 63 | + |
| 64 | +Probes use the **route's own action template** (not a separate code path). This is true black-box probing: probe IS the spawn: |
| 65 | + |
| 66 | +1. Render the action with `prompt = "ping"` |
| 67 | +2. Spawn the rendered command with a 30 s wall-clock cap (configurable) |
| 68 | +3. Read stdout/stderr lines until exit |
| 69 | +4. Classify outcome by content presence, not just exit code: |
| 70 | + - Healthy: at least one token-bearing event (e.g. opencode `type:"text"` line, or any non-empty stdout for stdout-based CLIs) |
| 71 | + - Truncated: exit 0 with no token content (the Z.AI-via-opencode case) -> mark unhealthy |
| 72 | + - Timeout: no exit within wall-clock cap -> unhealthy |
| 73 | + - Auth missing: stderr contains "No API key" / "ANTHROPIC_API_KEY not set" / etc -> unhealthy with `reason="auth"` |
| 74 | + - Endpoint error: exit non-zero with stderr matching network errors -> unhealthy with `reason="endpoint"` |
| 75 | + |
| 76 | +### Route selection |
| 77 | + |
| 78 | +`first_healthy_route` looks up each route by its 3-tuple: |
| 79 | + |
| 80 | +```rust |
| 81 | +let route_is_healthy = |r: &RouteDirective| -> bool { |
| 82 | + let key = ProbeKey { |
| 83 | + cli: r.cli_basename().unwrap_or("").to_string(), |
| 84 | + provider: r.provider.clone(), |
| 85 | + model: r.model.clone(), |
| 86 | + }; |
| 87 | + probe_cache.is_healthy(&key) |
| 88 | +}; |
| 89 | + |
| 90 | +decision.fallback_routes.iter().find(|r| route_is_healthy(r)) |
| 91 | +``` |
| 92 | + |
| 93 | +### Storage |
| 94 | + |
| 95 | +- `HashMap<ProbeKey, ProbeOutcome>` -- bounded by `|tiers| x |routes per tier|` -- realistic worst case is ~30-50 entries |
| 96 | +- TTL unchanged (1800 s default; new entries on miss) |
| 97 | +- Persisted under `~/.terraphim/benchmark-results/` per existing config |
| 98 | + |
| 99 | +## Self-healing properties |
| 100 | + |
| 101 | +After this design: |
| 102 | + |
| 103 | +| Scenario | Behaviour | |
| 104 | +|---|---| |
| 105 | +| opencode breaks for Z.AI (today) | `(opencode, zai-coding-plan, glm-5.1)` marked unhealthy; `(pi-rust, zai-coding-plan, glm-5.1)` stays healthy. KG router selects pi-rust route automatically. | |
| 106 | +| pi-rust breaks for some model | Symmetric: opencode route selected. | |
| 107 | +| Both CLIs break for same model | All routes for that `(provider, model)` are out of selection; KG router falls to the next route in priority order. | |
| 108 | +| Anthropic itself rate-limits | `(claude, anthropic, sonnet)` and `(opencode, anthropic, sonnet)` both unhealthy; KG router falls back to next-priority route exactly as today. | |
| 109 | + |
| 110 | +This is genuine **cross-CLI self-healing**: a broken CLI does not poison the model, and the orchestrator routes around it automatically on the next probe cycle. |
| 111 | + |
| 112 | +## Migration |
| 113 | + |
| 114 | +1. Probe cache file format: add a `cli` field. Old entries (without `cli`) treated as `cli = "opencode"` (the historical default) at load time, refreshed on next probe. |
| 115 | +2. KG router unchanged externally; only `first_healthy_route` internal lookup changes. |
| 116 | +3. No new TOML field on `AgentDefinition` -- the CLI is derived from the route's action template. |
| 117 | + |
| 118 | +## Implementation steps (new Step 0) |
| 119 | + |
| 120 | +Inserts **before** the existing 8-step plan; required for the per-CLI selection to work correctly post-deploy. |
| 121 | + |
| 122 | +| # | Action | Files | Tests | Hours | |
| 123 | +|---|---|---|---|---| |
| 124 | +| 0a | Add `cli_basename()` to `RouteDirective`; add `ProbeKey` 3-tuple in `provider_probe.rs`; rewrite probe cache lookup keyed by tuple | `crates/terraphim_orchestrator/src/kg_router.rs`, `provider_probe.rs` | `route_cli_basename_extracts_opencode`, `route_cli_basename_extracts_pi_rust`, `probe_key_distinguishes_cli` | 2 | |
| 125 | +| 0b | Rewrite `probe_provider` to use the route's action template + classify by content presence (not exit code alone) | `provider_probe.rs` | `probe_classifies_truncated_stream_as_unhealthy`, `probe_classifies_pong_as_healthy` | 2 | |
| 126 | +| 0c | `first_healthy_route` looks up 3-tuple | `kg_router.rs` | `first_healthy_route_keeps_pi_rust_zai_when_opencode_zai_unhealthy` (the exact regression that this design fixes) | 1 | |
| 127 | +| 0d | Probe cache file migration (load-time defaulting old `cli=null` to `opencode`) | `provider_probe.rs` | `legacy_probe_entry_migrates_to_opencode_cli` | 1 | |
| 128 | + |
| 129 | +**Total**: 6 hours. Net delta to overall plan: +6 h (was 14 h, now 20 h). But: the Z.AI taxonomy fix landed (~30 min) means Step 1 is already partially done and the remaining "investigate Z.AI" item collapses to "monitor whether per-CLI probe re-enables opencode route when fixed upstream". |
| 130 | + |
| 131 | +## Why this matters |
| 132 | + |
| 133 | +Without this, the orchestrator's self-healing claim is partial: it can route around an unhealthy provider, but not around an unhealthy CLI for a healthy provider. The Z.AI case is the existence proof that this gap matters in production today. |
| 134 | + |
| 135 | +With this, **the alternative-spawner pattern (pi-rust ↔ opencode) gains automatic failover** -- which was the whole point of adding pi-rust as a parallel CLI. The probe is what turns "two CLIs for the same model" from a theoretical option into operational redundancy. |
| 136 | + |
| 137 | +## Out of scope |
| 138 | + |
| 139 | +- Per-route circuit-breaker with state machine (Closed/Open/HalfOpen) -- existing `terraphim_spawner::health::CircuitBreaker` already handles that layer; we are extending its key, not its state machine |
| 140 | +- Streaming-token-rate probe (e.g. mark unhealthy if < 5 tokens/sec) -- premature; binary healthy/unhealthy is enough for the Z.AI case |
| 141 | +- Per-region probing -- single bigbox node, not relevant |
0 commit comments