cyberine
UsageRun recipes

Routing-explain — why a worker was picked or skipped (kc0nz)

Routing used to be a black box once the decision was made: reasons[] explained the winning fleet, but nothing recorded which OTHER candidates existed, or why a worker never even reached the router (a quota cooldown, a retired worker, a missing credential, a fleet.json restriction). buildRoutingSnapshot() (src/core/routingExplain.ts) turns a RouteDecision plus those candidate-pool exclusion notes into one structured "routing block" — every candidate's status (picked / considered / skipped), its rank, cost/tier metadata, resolved model, and the reason lines specific to it (split out from the generic classification/mode reasons).

The SAME block is surfaced in three places:

  1. cyberine route "<task>" (dry run) — the human-readable output now adds a considered (in pool, not picked): section and a skipped (never reached the router): section after the worker-profiles block; cyberine route --json "<task>" adds a routing key with the full snapshot.
  2. The TUI run detail view (npm run tui, open any run) — a routing line under the run header shows kind/tier, confidence, source, the picked worker ids, and a N considered, M skipped before dispatch count. Shown whenever the run carries a routing block; nothing to toggle.
  3. The MCP inspect_run tool — its response carries a routing key (same shape) whenever the run has one, via the shared buildInspectionView() (src/store/runInspection.ts).
# Dry run: see picked/considered/skipped candidates and why
cyberine route "refactor the entire auth module across every file"
cyberine route --json "refactor the entire auth module across every file" | jq .routing

# Background dispatch: the routing block lands on run.json at dispatch time
cyberine run --bg "fix the off-by-one bug in pagination"
cyberine agents   # find the runId, then:
cat .local/runs/<runId>/run.json | jq .routing

Schema (RoutingSnapshot, src/core/routingExplain.ts):

{
  "version": 2,
  "capturedAt": "2026-09-03T12:00:00.000Z",
  "kind": "code-large",
  "tier": "complex",
  "confidence": 0.9,
  "source": "deterministic",
  "recommendedMode": "compete",
  "picked": ["claude", "codex"],
  "failover": { "primary": ["claude", "codex"], "chain": [], "reasons": [], "billing": {} },
  "candidates": [
    {
      "id": "claude",
      "status": "picked",
      "rank": 1,
      "tier": "strong",
      "costModel": "subscription",
      "resolvedModel": "claude-sonnet-4-6",
      "reasons": ["claude available: preferred pool (subscription)", "claude preferred: tier=strong suits code-large task"]
    },
    { "id": "opencode-deepseek", "status": "considered", "rank": null, "reasons": ["opencode-deepseek excluded: metered native API (preferred-pool workers available)"] },
    { "id": "loop-kimi-build", "status": "skipped", "rank": null, "reasons": ["loop-kimi-build in quota cooldown (rate-limit) until 3:00:00 PM (~12m) - skipped"] }
  ],
  "generalReasons": ["classified as code-large: edit verbs + size signals (repo-wide / multi-file / long spec)", "recommendedMode=compete"]
}

Only the heuristic auto-router path populates run.json's routing field. It is built in src/launchBg.ts right after routeTaskSmart() decides the fleet, and handed to the child process via CYBERINE_ROUTING_SNAPSHOT_JSON so WorkflowEngine.run() can attach it to the run at creation time — so it appears on cyberine run --bg, chat /run, and the TUI's "new task" key. routing is ABSENT (not null — the key is missing) when:

  • the foreground cyberine run "<task>" (no --bg) never writes a run.json at all — there is nothing to attach it to;
  • an explicit --fleet <ids> or CYBERINE_BUILD_FLEET env pinned the fleet — no RouteDecision was made to describe;
  • a fleet.json routing map matched the task's kind and short-circuited the heuristic router entirely (resolveRoutingForKind);
  • the run.json predates this field (every reader — describeInvalidRun, buildInspectionView, the TUI, inspect_run — treats it as optional and tolerates its absence).

Known limitations and deferred work:

  • A worker excluded purely for a missing credential (no CLI login, no API key) is silently absent from candidates rather than appearing as status: "skipped" — availableBuildWorkerIds() only records an exclusion NOTE for cooldown, retirement, and fleet.json restrictions today, not for a bare "no key configured" case. Pass a notesOut array to see exactly what it does record.
  • Internal routeTask() call sites that select workers for something other than the top-level dispatch (src/engine/execStrategy.ts's GroupOrchestrator member selection, WorkflowScriptRuntime's per-script routing, beamSearch.ts's candidate exploration) do not attach a routing block to any run — this feature covers the ONE dispatch-time decision that picks a run's fleet, not every internal routing call in the codebase.
  • The snapshot is captured once, at dispatch time — it is not re-verified against what actually got built into adapters (buildDefaultAdapters/buildFleetAdapters could still drop a picked worker for a last-minute reason, e.g. a race with a credential expiring mid-dispatch); it answers "what did the router decide and why", not "what definitely ran".
  • src/eval/routerEval.ts still recomputes routeTask() fresh from each historical run's prompt rather than reading a stored routing block — the schema here is shaped to make that direct read straightforward (matching kind/tier/confidence/source naming) but the harness itself has not been switched over.

Regression guard: npm run selftest:routing-explain — asserts snapshot categorisation against both a synthetic RouteDecision and the real router, asserts availableBuildWorkerIds()'s notesOut parameter is additive, and (the core guard) asserts a fresh WorkflowEngine.run() call actually persists the routing block to run.json ON DISK, survives a RunStore.get() round-trip, and flows through buildInspectionView() — plus a backward-compatibility check that a routing-less Run still round-trips cleanly.