Skip to content

Commit 53728d4

Browse files
author
Test User
committed
Merge remote-tracking branch 'origin/main'
2 parents 88b832c + f542d49 commit 53728d4

92 files changed

Lines changed: 14524 additions & 116 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.docs/design-adf-agent-validation-plan.md

Lines changed: 374 additions & 0 deletions
Large diffs are not rendered by default.

.docs/design-adf-cli-full-agent-runner.md

Lines changed: 505 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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

Comments
 (0)